[skip ci] feat: prepare for public release
This commit is contained in:
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
custom: ['https://fluxer.app/donate']
|
||||||
5
.github/workflows/deploy-admin.yaml
vendored
5
.github/workflows/deploy-admin.yaml
vendored
@@ -70,6 +70,9 @@ jobs:
|
|||||||
echo "Deploying commit ${sha}"
|
echo "Deploying commit ${sha}"
|
||||||
printf 'DEPLOY_SHA=%s\n' "$sha" >> "$GITHUB_ENV"
|
printf 'DEPLOY_SHA=%s\n' "$sha" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Set build timestamp
|
||||||
|
run: echo "BUILD_TIMESTAMP=$(date -u +%s)" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
@@ -89,6 +92,8 @@ jobs:
|
|||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
cache-from: type=gha,scope=${{ env.CACHE_SCOPE }}
|
cache-from: type=gha,scope=${{ env.CACHE_SCOPE }}
|
||||||
cache-to: type=gha,mode=max,scope=${{ env.CACHE_SCOPE }}
|
cache-to: type=gha,mode=max,scope=${{ env.CACHE_SCOPE }}
|
||||||
|
build-args: |
|
||||||
|
BUILD_TIMESTAMP=${{ env.BUILD_TIMESTAMP }}
|
||||||
env:
|
env:
|
||||||
DOCKER_BUILD_SUMMARY: false
|
DOCKER_BUILD_SUMMARY: false
|
||||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||||
|
|||||||
6
.github/workflows/deploy-marketing.yaml
vendored
6
.github/workflows/deploy-marketing.yaml
vendored
@@ -74,6 +74,9 @@ jobs:
|
|||||||
echo "Deploying commit ${sha}"
|
echo "Deploying commit ${sha}"
|
||||||
printf 'DEPLOY_SHA=%s\n' "$sha" >> "$GITHUB_ENV"
|
printf 'DEPLOY_SHA=%s\n' "$sha" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Set build timestamp
|
||||||
|
run: echo "BUILD_TIMESTAMP=$(date -u +%s)" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
@@ -93,7 +96,8 @@ jobs:
|
|||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
cache-from: type=gha,scope=${{ env.CACHE_SCOPE }}
|
cache-from: type=gha,scope=${{ env.CACHE_SCOPE }}
|
||||||
cache-to: type=gha,mode=max,scope=${{ env.CACHE_SCOPE }}
|
cache-to: type=gha,mode=max,scope=${{ env.CACHE_SCOPE }}
|
||||||
build-args: BUILD_TIMESTAMP=${{ github.run_id }}
|
build-args: |
|
||||||
|
BUILD_TIMESTAMP=${{ env.BUILD_TIMESTAMP }}
|
||||||
env:
|
env:
|
||||||
DOCKER_BUILD_SUMMARY: false
|
DOCKER_BUILD_SUMMARY: false
|
||||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -1,6 +1,13 @@
|
|||||||
# Fluxer
|
<div align="left" style="margin:12px 0 8px;">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://fluxerstatic.com/marketing/branding/logo-white.svg">
|
||||||
|
<img src="https://fluxerstatic.com/marketing/branding/logo-black.svg" alt="Fluxer logo" width="360">
|
||||||
|
</picture>
|
||||||
|
</div>
|
||||||
|
|
||||||
Chat that puts you first. Built for friends, groups, and communities. Text, voice, and video. Open source and community-funded.
|
---
|
||||||
|
|
||||||
|
Fluxer is an open-source, independent instant messaging and VoIP platform. Built for friends, groups, and communities.
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Docs are coming very soon! With your help and [donations](https://fluxer.app/donate), the self-hosting and documentation story will get a lot better.
|
> Docs are coming very soon! With your help and [donations](https://fluxer.app/donate), the self-hosting and documentation story will get a lot better.
|
||||||
|
|||||||
@@ -25,8 +25,11 @@ import gleam/dynamic/decode
|
|||||||
import gleam/http
|
import gleam/http
|
||||||
import gleam/http/request
|
import gleam/http/request
|
||||||
import gleam/httpc
|
import gleam/httpc
|
||||||
|
import gleam/int
|
||||||
|
import gleam/io
|
||||||
import gleam/json
|
import gleam/json
|
||||||
import gleam/option
|
import gleam/option
|
||||||
|
import gleam/string
|
||||||
|
|
||||||
pub type Report {
|
pub type Report {
|
||||||
Report(
|
Report(
|
||||||
@@ -194,10 +197,7 @@ pub fn list_reports(
|
|||||||
"reported_guild_name",
|
"reported_guild_name",
|
||||||
decode.optional(decode.string),
|
decode.optional(decode.string),
|
||||||
)
|
)
|
||||||
use reported_guild_icon_hash <- decode.field(
|
let reported_guild_icon_hash = option.None
|
||||||
"reported_guild_icon_hash",
|
|
||||||
decode.optional(decode.string),
|
|
||||||
)
|
|
||||||
use reported_guild_invite_code <- decode.field(
|
use reported_guild_invite_code <- decode.field(
|
||||||
"reported_guild_invite_code",
|
"reported_guild_invite_code",
|
||||||
decode.optional(decode.string),
|
decode.optional(decode.string),
|
||||||
@@ -518,14 +518,15 @@ pub fn get_report_detail(
|
|||||||
|
|
||||||
let context_message_decoder = {
|
let context_message_decoder = {
|
||||||
use id <- decode.field("id", decode.string)
|
use id <- decode.field("id", decode.string)
|
||||||
use channel_id <- decode.field(
|
use channel_id <- decode.optional_field("channel_id", "", decode.string)
|
||||||
"channel_id",
|
use author_id <- decode.optional_field("author_id", "", decode.string)
|
||||||
decode.optional(decode.string),
|
use author_username <- decode.optional_field(
|
||||||
|
"author_username",
|
||||||
|
"",
|
||||||
|
decode.string,
|
||||||
)
|
)
|
||||||
use author_id <- decode.field("author_id", decode.string)
|
use content <- decode.optional_field("content", "", decode.string)
|
||||||
use author_username <- decode.field("author_username", decode.string)
|
use timestamp <- decode.optional_field("timestamp", "", decode.string)
|
||||||
use content <- decode.field("content", decode.string)
|
|
||||||
use timestamp <- decode.field("timestamp", decode.string)
|
|
||||||
use attachments <- decode.optional_field(
|
use attachments <- decode.optional_field(
|
||||||
"attachments",
|
"attachments",
|
||||||
[],
|
[],
|
||||||
@@ -533,7 +534,7 @@ pub fn get_report_detail(
|
|||||||
)
|
)
|
||||||
decode.success(Message(
|
decode.success(Message(
|
||||||
id: id,
|
id: id,
|
||||||
channel_id: option.unwrap(channel_id, ""),
|
channel_id: channel_id,
|
||||||
author_id: author_id,
|
author_id: author_id,
|
||||||
author_username: author_username,
|
author_username: author_username,
|
||||||
content: content,
|
content: content,
|
||||||
@@ -608,8 +609,9 @@ pub fn get_report_detail(
|
|||||||
"reported_guild_name",
|
"reported_guild_name",
|
||||||
decode.optional(decode.string),
|
decode.optional(decode.string),
|
||||||
)
|
)
|
||||||
use reported_guild_icon_hash <- decode.field(
|
use reported_guild_icon_hash <- decode.optional_field(
|
||||||
"reported_guild_icon_hash",
|
"reported_guild_icon_hash",
|
||||||
|
option.None,
|
||||||
decode.optional(decode.string),
|
decode.optional(decode.string),
|
||||||
)
|
)
|
||||||
use reported_guild_invite_code <- decode.field(
|
use reported_guild_invite_code <- decode.field(
|
||||||
@@ -680,7 +682,15 @@ pub fn get_report_detail(
|
|||||||
|
|
||||||
case json.parse(resp.body, report_decoder) {
|
case json.parse(resp.body, report_decoder) {
|
||||||
Ok(result) -> Ok(result)
|
Ok(result) -> Ok(result)
|
||||||
Error(_) -> Error(ServerError)
|
Error(err) -> {
|
||||||
|
io.println(
|
||||||
|
"reports.get_report_detail decode failed: "
|
||||||
|
<> string.inspect(err)
|
||||||
|
<> " body="
|
||||||
|
<> string.slice(resp.body, 0, 4000),
|
||||||
|
)
|
||||||
|
Error(ServerError)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||||
@@ -699,7 +709,20 @@ pub fn get_report_detail(
|
|||||||
Error(Forbidden(message))
|
Error(Forbidden(message))
|
||||||
}
|
}
|
||||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||||
Ok(_resp) -> Error(ServerError)
|
Ok(resp) -> {
|
||||||
Error(_) -> Error(NetworkError)
|
io.println(
|
||||||
|
"reports.get_report_detail unexpected status "
|
||||||
|
<> int.to_string(resp.status)
|
||||||
|
<> " body="
|
||||||
|
<> string.slice(resp.body, 0, 1000),
|
||||||
|
)
|
||||||
|
Error(ServerError)
|
||||||
|
}
|
||||||
|
Error(err) -> {
|
||||||
|
io.println(
|
||||||
|
"reports.get_report_detail network error: " <> string.inspect(err),
|
||||||
|
)
|
||||||
|
Error(NetworkError)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,21 +100,36 @@ pub fn page_with_refresh(
|
|||||||
[a.attribute("lang", "en"), a.attribute("data-base-path", ctx.base_path)],
|
[a.attribute("lang", "en"), a.attribute("data-base-path", ctx.base_path)],
|
||||||
[
|
[
|
||||||
build_head_with_refresh(title, ctx, auto_refresh),
|
build_head_with_refresh(title, ctx, auto_refresh),
|
||||||
h.body([a.class("min-h-screen bg-neutral-50 flex")], [
|
h.body([a.class("min-h-screen bg-neutral-50 overflow-hidden")], [
|
||||||
sidebar(ctx, active_page),
|
h.div([a.class("flex h-screen")], [
|
||||||
h.div([a.class("ml-64 flex-1 flex flex-col")], [
|
sidebar(ctx, active_page),
|
||||||
header(ctx, session, current_admin),
|
h.div(
|
||||||
h.main([a.class("flex-1 p-8")], [
|
[
|
||||||
h.div([a.class("max-w-7xl mx-auto")], [
|
a.attribute("data-sidebar-overlay", ""),
|
||||||
case flash_data {
|
a.class("fixed inset-0 bg-black/50 z-30 hidden lg:hidden"),
|
||||||
option.Some(_) ->
|
],
|
||||||
h.div([a.class("mb-6")], [flash.view(flash_data)])
|
[],
|
||||||
option.None -> element.none()
|
),
|
||||||
},
|
h.div(
|
||||||
content,
|
[
|
||||||
]),
|
a.class("flex-1 flex flex-col w-full h-screen overflow-y-auto"),
|
||||||
]),
|
],
|
||||||
|
[
|
||||||
|
header(ctx, session, current_admin),
|
||||||
|
h.main([a.class("flex-1 p-4 sm:p-6 lg:p-8")], [
|
||||||
|
h.div([a.class("w-full max-w-7xl mx-auto")], [
|
||||||
|
case flash_data {
|
||||||
|
option.Some(_) ->
|
||||||
|
h.div([a.class("mb-6")], [flash.view(flash_data)])
|
||||||
|
option.None -> element.none()
|
||||||
|
},
|
||||||
|
content,
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
|
sidebar_interaction_script(),
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -123,18 +138,37 @@ pub fn page_with_refresh(
|
|||||||
fn sidebar(ctx: Context, active_page: String) {
|
fn sidebar(ctx: Context, active_page: String) {
|
||||||
h.div(
|
h.div(
|
||||||
[
|
[
|
||||||
|
a.attribute("data-sidebar", ""),
|
||||||
a.class(
|
a.class(
|
||||||
"w-64 bg-neutral-900 text-white flex flex-col h-screen fixed left-0 top-0",
|
"fixed inset-y-0 left-0 z-40 w-64 h-screen bg-neutral-900 text-white flex flex-col transform -translate-x-full transition-transform duration-200 ease-in-out lg:translate-x-0 lg:static lg:inset-auto shadow-xl lg:shadow-none",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
h.div([a.class("p-6 border-b border-neutral-800")], [
|
h.div(
|
||||||
h.a([href(ctx, "/users")], [
|
[
|
||||||
h.h1([a.class("text-base font-semibold")], [
|
a.class(
|
||||||
element.text("Fluxer Admin"),
|
"p-6 border-b border-neutral-800 flex items-center justify-between gap-3",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
h.a([href(ctx, "/users")], [
|
||||||
|
h.h1([a.class("text-base font-semibold")], [
|
||||||
|
element.text("Fluxer Admin"),
|
||||||
|
]),
|
||||||
]),
|
]),
|
||||||
]),
|
h.button(
|
||||||
]),
|
[
|
||||||
|
a.type_("button"),
|
||||||
|
a.attribute("data-sidebar-close", ""),
|
||||||
|
a.class(
|
||||||
|
"lg:hidden inline-flex items-center justify-center p-2 rounded-md text-neutral-200 hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-white/40",
|
||||||
|
),
|
||||||
|
a.attribute("aria-label", "Close sidebar"),
|
||||||
|
],
|
||||||
|
[element.text("Close")],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
h.nav(
|
h.nav(
|
||||||
[
|
[
|
||||||
a.class("flex-1 overflow-y-auto p-4 space-y-1 sidebar-scrollbar"),
|
a.class("flex-1 overflow-y-auto p-4 space-y-1 sidebar-scrollbar"),
|
||||||
@@ -304,11 +338,68 @@ fn header(
|
|||||||
h.header(
|
h.header(
|
||||||
[
|
[
|
||||||
a.class(
|
a.class(
|
||||||
"bg-white border-b border-neutral-200 px-8 py-4 flex items-center justify-between",
|
"bg-white border-b border-neutral-200 px-4 sm:px-6 lg:px-8 py-4 flex items-center justify-between gap-4 sticky top-0 z-10",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
render_user_info(ctx, session, current_admin),
|
h.div([a.class("flex items-center gap-3 min-w-0")], [
|
||||||
|
h.button(
|
||||||
|
[
|
||||||
|
a.type_("button"),
|
||||||
|
a.attribute("data-sidebar-toggle", ""),
|
||||||
|
a.class(
|
||||||
|
"lg:hidden inline-flex items-center justify-center p-2 rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-100 focus:outline-none focus:ring-2 focus:ring-neutral-400",
|
||||||
|
),
|
||||||
|
a.attribute("aria-label", "Toggle sidebar"),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
element.element(
|
||||||
|
"svg",
|
||||||
|
[
|
||||||
|
a.attribute("xmlns", "http://www.w3.org/2000/svg"),
|
||||||
|
a.attribute("viewBox", "0 0 24 24"),
|
||||||
|
a.class("w-5 h-5"),
|
||||||
|
a.attribute("fill", "none"),
|
||||||
|
a.attribute("stroke", "currentColor"),
|
||||||
|
a.attribute("stroke-width", "2"),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
element.element(
|
||||||
|
"line",
|
||||||
|
[
|
||||||
|
a.attribute("x1", "3"),
|
||||||
|
a.attribute("y1", "6"),
|
||||||
|
a.attribute("x2", "21"),
|
||||||
|
a.attribute("y2", "6"),
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
element.element(
|
||||||
|
"line",
|
||||||
|
[
|
||||||
|
a.attribute("x1", "3"),
|
||||||
|
a.attribute("y1", "12"),
|
||||||
|
a.attribute("x2", "21"),
|
||||||
|
a.attribute("y2", "12"),
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
element.element(
|
||||||
|
"line",
|
||||||
|
[
|
||||||
|
a.attribute("x1", "3"),
|
||||||
|
a.attribute("y1", "18"),
|
||||||
|
a.attribute("x2", "21"),
|
||||||
|
a.attribute("y2", "18"),
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
render_user_info(ctx, session, current_admin),
|
||||||
|
]),
|
||||||
h.a(
|
h.a(
|
||||||
[
|
[
|
||||||
href(ctx, "/logout"),
|
href(ctx, "/logout"),
|
||||||
@@ -390,3 +481,58 @@ fn render_avatar(
|
|||||||
a.class("w-10 h-10 rounded-full"),
|
a.class("w-10 h-10 rounded-full"),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sidebar_interaction_script() {
|
||||||
|
h.script(
|
||||||
|
[a.attribute("defer", "defer")],
|
||||||
|
"
|
||||||
|
(function() {
|
||||||
|
const sidebar = document.querySelector('[data-sidebar]');
|
||||||
|
const overlay = document.querySelector('[data-sidebar-overlay]');
|
||||||
|
const toggles = document.querySelectorAll('[data-sidebar-toggle]');
|
||||||
|
const closes = document.querySelectorAll('[data-sidebar-close]');
|
||||||
|
if (!sidebar || !overlay) return;
|
||||||
|
|
||||||
|
const open = () => {
|
||||||
|
sidebar.classList.remove('-translate-x-full');
|
||||||
|
overlay.classList.remove('hidden');
|
||||||
|
document.body.classList.add('overflow-hidden');
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
sidebar.classList.add('-translate-x-full');
|
||||||
|
overlay.classList.add('hidden');
|
||||||
|
document.body.classList.remove('overflow-hidden');
|
||||||
|
};
|
||||||
|
|
||||||
|
toggles.forEach((btn) => btn.addEventListener('click', () => {
|
||||||
|
if (sidebar.classList.contains('-translate-x-full')) {
|
||||||
|
open();
|
||||||
|
} else {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
closes.forEach((btn) => btn.addEventListener('click', close));
|
||||||
|
overlay.addEventListener('click', close);
|
||||||
|
|
||||||
|
window.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape') close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const syncForDesktop = () => {
|
||||||
|
if (window.innerWidth >= 1024) {
|
||||||
|
overlay.classList.add('hidden');
|
||||||
|
document.body.classList.remove('overflow-hidden');
|
||||||
|
sidebar.classList.remove('-translate-x-full');
|
||||||
|
} else {
|
||||||
|
sidebar.classList.add('-translate-x-full');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('resize', syncForDesktop);
|
||||||
|
syncForDesktop();
|
||||||
|
})();
|
||||||
|
",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ pub type Tab {
|
|||||||
pub fn render_tabs(ctx: Context, tabs: List(Tab)) -> element.Element(a) {
|
pub fn render_tabs(ctx: Context, tabs: List(Tab)) -> element.Element(a) {
|
||||||
h.div([a.class("border-b border-neutral-200 mb-6")], [
|
h.div([a.class("border-b border-neutral-200 mb-6")], [
|
||||||
h.nav(
|
h.nav(
|
||||||
[a.class("flex gap-6")],
|
[a.class("flex gap-6 overflow-x-auto no-scrollbar -mb-px px-1")],
|
||||||
list.map(tabs, fn(tab) { render_tab(ctx, tab) }),
|
list.map(tabs, fn(tab) { render_tab(ctx, tab) }),
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
@@ -36,9 +36,10 @@ pub fn render_tabs(ctx: Context, tabs: List(Tab)) -> element.Element(a) {
|
|||||||
|
|
||||||
fn render_tab(ctx: Context, tab: Tab) -> element.Element(a) {
|
fn render_tab(ctx: Context, tab: Tab) -> element.Element(a) {
|
||||||
let class_active = case tab.active {
|
let class_active = case tab.active {
|
||||||
True -> "border-b-2 border-neutral-900 text-neutral-900 text-sm pb-3"
|
True ->
|
||||||
|
"border-b-2 border-neutral-900 text-neutral-900 text-sm pb-3 whitespace-nowrap"
|
||||||
False ->
|
False ->
|
||||||
"border-b-2 border-transparent text-neutral-600 hover:text-neutral-900 hover:border-neutral-300 text-sm pb-3 transition-colors"
|
"border-b-2 border-transparent text-neutral-600 hover:text-neutral-900 hover:border-neutral-300 text-sm pb-3 transition-colors whitespace-nowrap"
|
||||||
}
|
}
|
||||||
|
|
||||||
h.a([href(ctx, tab.path), a.class(class_active)], [element.text(tab.label)])
|
h.a([href(ctx, tab.path), a.class(class_active)], [element.text(tab.label)])
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import lustre/attribute as a
|
|||||||
import lustre/element
|
import lustre/element
|
||||||
import lustre/element/html as h
|
import lustre/element/html as h
|
||||||
|
|
||||||
pub const table_container_class = "bg-white border border-neutral-200 rounded-lg overflow-hidden"
|
pub const table_container_class = "bg-white border border-neutral-200 rounded-lg overflow-hidden overflow-x-auto"
|
||||||
|
|
||||||
pub const table_header_cell_class = "px-6 py-3 text-left text-xs text-neutral-600 uppercase tracking-wider"
|
pub const table_header_cell_class = "px-6 py-3 text-left text-xs text-neutral-600 uppercase tracking-wider"
|
||||||
|
|
||||||
@@ -469,7 +469,10 @@ pub fn flex_row(
|
|||||||
pub fn flex_row_between(
|
pub fn flex_row_between(
|
||||||
children: List(element.Element(a)),
|
children: List(element.Element(a)),
|
||||||
) -> element.Element(a) {
|
) -> element.Element(a) {
|
||||||
h.div([a.class("mb-6 flex items-center justify-between")], children)
|
h.div(
|
||||||
|
[a.class("mb-6 flex flex-wrap items-center justify-between gap-3")],
|
||||||
|
children,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn stack(
|
pub fn stack(
|
||||||
@@ -570,35 +573,37 @@ pub fn data_table(
|
|||||||
h.div(
|
h.div(
|
||||||
[a.class("bg-white border border-neutral-200 rounded-lg overflow-hidden")],
|
[a.class("bg-white border border-neutral-200 rounded-lg overflow-hidden")],
|
||||||
[
|
[
|
||||||
h.table([a.class("min-w-full divide-y divide-neutral-200")], [
|
h.div([a.class("overflow-x-auto")], [
|
||||||
h.thead([a.class("bg-neutral-50")], [
|
h.table([a.class("min-w-full divide-y divide-neutral-200")], [
|
||||||
h.tr(
|
h.thead([a.class("bg-neutral-50")], [
|
||||||
[],
|
h.tr(
|
||||||
list.map(columns, fn(col) {
|
[],
|
||||||
let TableColumn(header, _, _) = col
|
list.map(columns, fn(col) {
|
||||||
h.th(
|
let TableColumn(header, _, _) = col
|
||||||
[
|
h.th(
|
||||||
a.class(
|
[
|
||||||
"px-6 py-3 text-left text-xs text-neutral-600 uppercase tracking-wider",
|
a.class(
|
||||||
),
|
"px-6 py-3 text-left text-xs text-neutral-600 uppercase tracking-wider",
|
||||||
],
|
),
|
||||||
[element.text(header)],
|
],
|
||||||
|
[element.text(header)],
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
h.tbody(
|
||||||
|
[a.class("bg-white divide-y divide-neutral-200")],
|
||||||
|
list.map(rows, fn(row) {
|
||||||
|
h.tr(
|
||||||
|
[a.class("hover:bg-neutral-50 transition-colors")],
|
||||||
|
list.map(columns, fn(col) {
|
||||||
|
let TableColumn(_, cell_class, render) = col
|
||||||
|
h.td([a.class(cell_class)], [render(row)])
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
h.tbody(
|
|
||||||
[a.class("bg-white divide-y divide-neutral-200")],
|
|
||||||
list.map(rows, fn(row) {
|
|
||||||
h.tr(
|
|
||||||
[a.class("hover:bg-neutral-50 transition-colors")],
|
|
||||||
list.map(columns, fn(col) {
|
|
||||||
let TableColumn(_, cell_class, render) = col
|
|
||||||
h.td([a.class(cell_class)], [render(row)])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -629,7 +634,7 @@ pub fn custom_checkbox(
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
h.label([a.class("flex items-center gap-3 cursor-pointer group")], [
|
h.label([a.class("flex items-center gap-3 cursor-pointer group w-full")], [
|
||||||
h.input(checkbox_attrs),
|
h.input(checkbox_attrs),
|
||||||
element.element(
|
element.element(
|
||||||
"svg",
|
"svg",
|
||||||
@@ -637,7 +642,7 @@ pub fn custom_checkbox(
|
|||||||
a.attribute("xmlns", "http://www.w3.org/2000/svg"),
|
a.attribute("xmlns", "http://www.w3.org/2000/svg"),
|
||||||
a.attribute("viewBox", "0 0 256 256"),
|
a.attribute("viewBox", "0 0 256 256"),
|
||||||
a.class(
|
a.class(
|
||||||
"w-5 h-5 bg-white border-2 border-neutral-300 rounded p-0.5 text-white peer-checked:bg-neutral-900 peer-checked:border-neutral-900 transition-colors",
|
"w-5 h-5 bg-white border-2 border-neutral-300 rounded p-0.5 text-white peer-checked:bg-neutral-900 peer-checked:border-neutral-900 transition-colors flex-shrink-0",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -655,8 +660,15 @@ pub fn custom_checkbox(
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
h.span([a.class("text-sm text-neutral-900 group-hover:text-neutral-700")], [
|
h.div([a.class("flex-1 min-w-0")], [
|
||||||
element.text(label),
|
h.span(
|
||||||
|
[
|
||||||
|
a.class(
|
||||||
|
"text-sm text-neutral-900 group-hover:text-neutral-700 leading-snug truncate",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[element.text(label)],
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -259,6 +259,10 @@ pub const acl_user_delete = "user:delete"
|
|||||||
|
|
||||||
pub const acl_user_cancel_bulk_message_deletion = "user:cancel:bulk_message_deletion"
|
pub const acl_user_cancel_bulk_message_deletion = "user:cancel:bulk_message_deletion"
|
||||||
|
|
||||||
|
pub const acl_pending_verification_view = "pending_verification:view"
|
||||||
|
|
||||||
|
pub const acl_pending_verification_review = "pending_verification:review"
|
||||||
|
|
||||||
pub const acl_beta_codes_generate = "beta_codes:generate"
|
pub const acl_beta_codes_generate = "beta_codes:generate"
|
||||||
|
|
||||||
pub const acl_gift_codes_generate = "gift_codes:generate"
|
pub const acl_gift_codes_generate = "gift_codes:generate"
|
||||||
@@ -506,6 +510,8 @@ pub fn get_all_acls() -> List(String) {
|
|||||||
acl_user_disable_suspicious,
|
acl_user_disable_suspicious,
|
||||||
acl_user_delete,
|
acl_user_delete,
|
||||||
acl_user_cancel_bulk_message_deletion,
|
acl_user_cancel_bulk_message_deletion,
|
||||||
|
acl_pending_verification_view,
|
||||||
|
acl_pending_verification_review,
|
||||||
acl_beta_codes_generate,
|
acl_beta_codes_generate,
|
||||||
acl_gift_codes_generate,
|
acl_gift_codes_generate,
|
||||||
acl_guild_lookup,
|
acl_guild_lookup,
|
||||||
|
|||||||
@@ -118,60 +118,81 @@ pub fn view(
|
|||||||
h.div(
|
h.div(
|
||||||
[a.class("bg-white border border-neutral-200 rounded-lg p-6 mb-6")],
|
[a.class("bg-white border border-neutral-200 rounded-lg p-6 mb-6")],
|
||||||
[
|
[
|
||||||
h.div([a.class("flex items-start gap-6")], [
|
h.div(
|
||||||
case
|
[
|
||||||
avatar.get_guild_icon_url(
|
a.class(
|
||||||
ctx.media_endpoint,
|
"flex flex-col gap-4 sm:flex-row sm:items-start sm:gap-6",
|
||||||
guild_data.id,
|
),
|
||||||
guild_data.icon,
|
],
|
||||||
True,
|
[
|
||||||
)
|
case
|
||||||
{
|
avatar.get_guild_icon_url(
|
||||||
option.Some(icon_url) ->
|
ctx.media_endpoint,
|
||||||
h.div([a.class("flex-shrink-0")], [
|
guild_data.id,
|
||||||
h.img([
|
guild_data.icon,
|
||||||
a.src(icon_url),
|
True,
|
||||||
a.alt(guild_data.name),
|
)
|
||||||
a.class("w-24 h-24 rounded-full"),
|
{
|
||||||
]),
|
option.Some(icon_url) ->
|
||||||
])
|
|
||||||
option.None ->
|
|
||||||
h.div([a.class("flex-shrink-0")], [
|
|
||||||
h.div(
|
h.div(
|
||||||
[
|
[
|
||||||
a.class(
|
a.class(
|
||||||
"w-24 h-24 rounded-full bg-neutral-200 flex items-center justify-center text-base font-semibold text-neutral-600",
|
"flex-shrink-0 flex items-center sm:block justify-center",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
element.text(avatar.get_initials_from_name(
|
h.img([
|
||||||
guild_data.name,
|
a.src(icon_url),
|
||||||
)),
|
a.alt(guild_data.name),
|
||||||
|
a.class("w-24 h-24 rounded-full"),
|
||||||
|
]),
|
||||||
],
|
],
|
||||||
),
|
)
|
||||||
])
|
option.None ->
|
||||||
},
|
h.div(
|
||||||
ui.detail_header(guild_data.name, [
|
[
|
||||||
#(
|
a.class(
|
||||||
"Guild ID:",
|
"flex-shrink-0 flex items-center sm:block justify-center",
|
||||||
h.div([a.class("text-sm text-neutral-900")], [
|
),
|
||||||
element.text(guild_data.id),
|
],
|
||||||
]),
|
[
|
||||||
),
|
h.div(
|
||||||
#(
|
[
|
||||||
"Owner ID:",
|
a.class(
|
||||||
h.a(
|
"w-24 h-24 rounded-full bg-neutral-200 flex items-center justify-center text-base font-semibold text-neutral-600",
|
||||||
[
|
),
|
||||||
href(ctx, "/users/" <> guild_data.owner_id),
|
],
|
||||||
a.class(
|
[
|
||||||
"text-sm text-neutral-900 hover:text-blue-600 hover:underline",
|
element.text(avatar.get_initials_from_name(
|
||||||
),
|
guild_data.name,
|
||||||
],
|
)),
|
||||||
[element.text(guild_data.owner_id)],
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
},
|
||||||
|
ui.detail_header(guild_data.name, [
|
||||||
|
#(
|
||||||
|
"Guild ID:",
|
||||||
|
h.div([a.class("text-sm text-neutral-900 break-all")], [
|
||||||
|
element.text(guild_data.id),
|
||||||
|
]),
|
||||||
),
|
),
|
||||||
),
|
#(
|
||||||
]),
|
"Owner ID:",
|
||||||
]),
|
h.a(
|
||||||
|
[
|
||||||
|
href(ctx, "/users/" <> guild_data.owner_id),
|
||||||
|
a.class(
|
||||||
|
"text-sm text-neutral-900 hover:text-blue-600 hover:underline",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[element.text(guild_data.owner_id)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
render_tabs(
|
render_tabs(
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ pub fn view(
|
|||||||
fn render_search_form(ctx: Context, query: option.Option(String)) {
|
fn render_search_form(ctx: Context, query: option.Option(String)) {
|
||||||
ui.card(ui.PaddingSmall, [
|
ui.card(ui.PaddingSmall, [
|
||||||
h.form([a.method("get"), a.class("flex flex-col gap-4")], [
|
h.form([a.method("get"), a.class("flex flex-col gap-4")], [
|
||||||
h.div([a.class("flex gap-2")], [
|
h.div([a.class("flex flex-col sm:flex-row gap-2")], [
|
||||||
h.input([
|
h.input([
|
||||||
a.type_("text"),
|
a.type_("text"),
|
||||||
a.name("q"),
|
a.name("q"),
|
||||||
@@ -123,12 +123,12 @@ fn render_search_form(ctx: Context, query: option.Option(String)) {
|
|||||||
),
|
),
|
||||||
a.attribute("autocomplete", "off"),
|
a.attribute("autocomplete", "off"),
|
||||||
]),
|
]),
|
||||||
ui.button_primary("Search", "submit", []),
|
ui.button_primary("Search", "submit", [a.class("w-full sm:w-auto")]),
|
||||||
h.a(
|
h.a(
|
||||||
[
|
[
|
||||||
href(ctx, "/guilds"),
|
href(ctx, "/guilds"),
|
||||||
a.class(
|
a.class(
|
||||||
"px-4 py-2 bg-white text-neutral-700 border border-neutral-300 rounded-lg text-sm font-medium hover:bg-neutral-50 transition-colors",
|
"px-4 py-2 bg-white text-neutral-700 border border-neutral-300 rounded-lg text-sm font-medium hover:bg-neutral-50 transition-colors w-full sm:w-auto text-center",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
[element.text("Clear")],
|
[element.text("Clear")],
|
||||||
@@ -159,7 +159,7 @@ fn render_guild_card(ctx: Context, guild: guilds.GuildSearchResult) {
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
h.div([a.class("p-5")], [
|
h.div([a.class("p-5")], [
|
||||||
h.div([a.class("flex items-center gap-4")], [
|
h.div([a.class("flex flex-col sm:flex-row sm:items-center gap-4")], [
|
||||||
case
|
case
|
||||||
avatar.get_guild_icon_url(
|
avatar.get_guild_icon_url(
|
||||||
ctx.media_endpoint,
|
ctx.media_endpoint,
|
||||||
@@ -169,27 +169,41 @@ fn render_guild_card(ctx: Context, guild: guilds.GuildSearchResult) {
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
option.Some(icon_url) ->
|
option.Some(icon_url) ->
|
||||||
h.div([a.class("flex-shrink-0")], [
|
h.div(
|
||||||
h.img([
|
[
|
||||||
a.src(icon_url),
|
a.class(
|
||||||
a.alt(guild.name),
|
"flex-shrink-0 flex items-center sm:block justify-center",
|
||||||
a.class("w-16 h-16 rounded-full"),
|
),
|
||||||
]),
|
],
|
||||||
])
|
[
|
||||||
|
h.img([
|
||||||
|
a.src(icon_url),
|
||||||
|
a.alt(guild.name),
|
||||||
|
a.class("w-16 h-16 rounded-full"),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
)
|
||||||
option.None ->
|
option.None ->
|
||||||
h.div([a.class("flex-shrink-0")], [
|
h.div(
|
||||||
h.div(
|
[
|
||||||
[
|
a.class(
|
||||||
a.class(
|
"flex-shrink-0 flex items-center sm:block justify-center",
|
||||||
"w-16 h-16 rounded-full bg-neutral-200 flex items-center justify-center text-base font-medium text-neutral-600",
|
),
|
||||||
),
|
],
|
||||||
],
|
[
|
||||||
[element.text(avatar.get_initials_from_name(guild.name))],
|
h.div(
|
||||||
),
|
[
|
||||||
])
|
a.class(
|
||||||
|
"w-16 h-16 rounded-full bg-neutral-200 flex items-center justify-center text-base font-medium text-neutral-600",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[element.text(avatar.get_initials_from_name(guild.name))],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
},
|
},
|
||||||
h.div([a.class("flex-1 min-w-0")], [
|
h.div([a.class("flex-1 min-w-0")], [
|
||||||
h.div([a.class("flex items-center gap-2 mb-2")], [
|
h.div([a.class("flex items-center gap-2 mb-2 flex-wrap")], [
|
||||||
h.h2([a.class("text-base font-medium text-neutral-900")], [
|
h.h2([a.class("text-base font-medium text-neutral-900")], [
|
||||||
element.text(guild.name),
|
element.text(guild.name),
|
||||||
]),
|
]),
|
||||||
@@ -207,7 +221,7 @@ fn render_guild_card(ctx: Context, guild: guilds.GuildSearchResult) {
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
h.div([a.class("space-y-0.5")], [
|
h.div([a.class("space-y-0.5")], [
|
||||||
h.div([a.class("text-sm text-neutral-600")], [
|
h.div([a.class("text-sm text-neutral-600 break-all")], [
|
||||||
element.text("ID: " <> guild.id),
|
element.text("ID: " <> guild.id),
|
||||||
]),
|
]),
|
||||||
h.div([a.class("text-sm text-neutral-600")], [
|
h.div([a.class("text-sm text-neutral-600")], [
|
||||||
|
|||||||
@@ -15,18 +15,16 @@
|
|||||||
//// You should have received a copy of the GNU Affero General Public License
|
//// You should have received a copy of the GNU Affero General Public License
|
||||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import fluxer_admin/acl
|
||||||
import fluxer_admin/api/common
|
import fluxer_admin/api/common
|
||||||
import fluxer_admin/api/verifications
|
import fluxer_admin/api/verifications
|
||||||
import fluxer_admin/avatar
|
import fluxer_admin/avatar
|
||||||
import fluxer_admin/components/flash
|
import fluxer_admin/components/flash
|
||||||
import fluxer_admin/components/layout
|
import fluxer_admin/components/layout
|
||||||
import fluxer_admin/components/review_deck
|
|
||||||
import fluxer_admin/components/review_hintbar
|
|
||||||
import fluxer_admin/components/ui
|
import fluxer_admin/components/ui
|
||||||
|
import fluxer_admin/constants
|
||||||
import fluxer_admin/user
|
import fluxer_admin/user
|
||||||
import fluxer_admin/web.{
|
import fluxer_admin/web.{type Context, type Session, action, href}
|
||||||
type Context, type Session, action, href, prepend_base_path,
|
|
||||||
}
|
|
||||||
import gleam/int
|
import gleam/int
|
||||||
import gleam/list
|
import gleam/list
|
||||||
import gleam/option
|
import gleam/option
|
||||||
@@ -47,6 +45,60 @@ const suspicious_user_agent_keywords = [
|
|||||||
"go-http-client",
|
"go-http-client",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
fn selection_toolbar() -> element.Element(a) {
|
||||||
|
h.div(
|
||||||
|
[
|
||||||
|
a.class(
|
||||||
|
"mt-4 flex items-center justify-between gap-3 bg-neutral-50 border border-neutral-200 rounded-lg px-3 py-2",
|
||||||
|
),
|
||||||
|
a.attribute("data-selection-toolbar", "true"),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
h.div([a.class("flex items-center gap-3")], [
|
||||||
|
h.input([
|
||||||
|
a.type_("checkbox"),
|
||||||
|
a.attribute("data-select-all", "true"),
|
||||||
|
a.class("h-4 w-4 rounded border-neutral-300"),
|
||||||
|
]),
|
||||||
|
h.span([a.class("text-sm text-neutral-700")], [
|
||||||
|
element.text("Select all visible"),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
h.div([a.class("flex items-center gap-3 flex-wrap justify-end")], [
|
||||||
|
h.span(
|
||||||
|
[
|
||||||
|
a.attribute("data-selected-count", "true"),
|
||||||
|
a.class("text-sm text-neutral-600"),
|
||||||
|
],
|
||||||
|
[element.text("0 selected")],
|
||||||
|
),
|
||||||
|
h.div([a.class("flex items-center gap-2")], [
|
||||||
|
h.button(
|
||||||
|
[
|
||||||
|
a.attribute("data-bulk-action", "reject"),
|
||||||
|
a.class(
|
||||||
|
"px-3 py-1.5 bg-red-600 text-white rounded-lg text-sm disabled:opacity-40 disabled:cursor-not-allowed",
|
||||||
|
),
|
||||||
|
a.disabled(True),
|
||||||
|
],
|
||||||
|
[element.text("Reject selected")],
|
||||||
|
),
|
||||||
|
h.button(
|
||||||
|
[
|
||||||
|
a.attribute("data-bulk-action", "approve"),
|
||||||
|
a.class(
|
||||||
|
"px-3 py-1.5 bg-green-600 text-white rounded-lg text-sm disabled:opacity-40 disabled:cursor-not-allowed",
|
||||||
|
),
|
||||||
|
a.disabled(True),
|
||||||
|
],
|
||||||
|
[element.text("Approve selected")],
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn view(
|
pub fn view(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
session: Session,
|
session: Session,
|
||||||
@@ -55,6 +107,12 @@ pub fn view(
|
|||||||
) -> Response {
|
) -> Response {
|
||||||
let limit = 50
|
let limit = 50
|
||||||
let result = verifications.list_pending_verifications(ctx, session, limit)
|
let result = verifications.list_pending_verifications(ctx, session, limit)
|
||||||
|
let admin_acls = case current_admin {
|
||||||
|
option.Some(admin) -> admin.acls
|
||||||
|
option.None -> []
|
||||||
|
}
|
||||||
|
let can_review =
|
||||||
|
acl.has_permission(admin_acls, constants.acl_pending_verification_review)
|
||||||
|
|
||||||
let content = case result {
|
let content = case result {
|
||||||
Ok(response) -> {
|
Ok(response) -> {
|
||||||
@@ -75,72 +133,31 @@ pub fn view(
|
|||||||
h.span(
|
h.span(
|
||||||
[
|
[
|
||||||
a.class("body-sm text-neutral-600"),
|
a.class("body-sm text-neutral-600"),
|
||||||
a.attribute("data-review-progress", ""),
|
a.attribute("data-remaining-total", "true"),
|
||||||
],
|
|
||||||
[
|
|
||||||
element.text(int.to_string(count) <> " remaining"),
|
|
||||||
],
|
],
|
||||||
|
[element.text(int.to_string(count) <> " remaining")],
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
|
case can_review {
|
||||||
|
True -> selection_toolbar()
|
||||||
|
False -> element.none()
|
||||||
|
},
|
||||||
case list.is_empty(response.pending_verifications) {
|
case list.is_empty(response.pending_verifications) {
|
||||||
True -> empty_state()
|
True -> empty_state()
|
||||||
False ->
|
False ->
|
||||||
h.div(
|
h.div([a.class("mt-4")], [
|
||||||
[a.class("mt-4")],
|
h.div(
|
||||||
list.append(
|
[
|
||||||
[review_deck.styles()],
|
a.class("grid gap-4 md:grid-cols-2 xl:grid-cols-3"),
|
||||||
list.append(review_deck.script_tags(), [
|
a.attribute("data-select-grid", "true"),
|
||||||
h.div(
|
],
|
||||||
[
|
list.map(response.pending_verifications, fn(pv) {
|
||||||
a.attribute("data-review-deck", "true"),
|
render_pending_verification_card(ctx, pv, can_review)
|
||||||
a.attribute(
|
}),
|
||||||
"data-fragment-base",
|
|
||||||
prepend_base_path(
|
|
||||||
ctx,
|
|
||||||
"/pending-verifications/fragment",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
a.attribute("data-next-page", "2"),
|
|
||||||
a.attribute("data-can-paginate", "true"),
|
|
||||||
a.attribute("data-prefetch-when-remaining", "6"),
|
|
||||||
a.attribute(
|
|
||||||
"data-empty-url",
|
|
||||||
prepend_base_path(ctx, "/pending-verifications"),
|
|
||||||
),
|
|
||||||
a.tabindex(0),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
h.div(
|
|
||||||
[a.class("max-w-2xl mx-auto")],
|
|
||||||
list.map(response.pending_verifications, fn(pv) {
|
|
||||||
render_pending_verification_card(ctx, pv)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
h.div(
|
|
||||||
[
|
|
||||||
a.attribute("data-review-progress", "true"),
|
|
||||||
a.class("text-center mt-4 body-sm text-neutral-600"),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
element.text(int.to_string(total) <> " remaining"),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
review_hintbar.view(
|
|
||||||
"←",
|
|
||||||
"Reject",
|
|
||||||
"→",
|
|
||||||
"Approve",
|
|
||||||
"Esc",
|
|
||||||
"Exit",
|
|
||||||
option.Some("Swipe cards on touch devices"),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
),
|
),
|
||||||
)
|
])
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -156,12 +173,17 @@ pub fn view(
|
|||||||
session,
|
session,
|
||||||
current_admin,
|
current_admin,
|
||||||
flash_data,
|
flash_data,
|
||||||
content,
|
h.div([], [content, pending_verifications_script()]),
|
||||||
)
|
)
|
||||||
wisp.html_response(element.to_document_string(html), 200)
|
wisp.html_response(element.to_document_string(html), 200)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn view_fragment(ctx: Context, session: Session, page: Int) -> Response {
|
pub fn view_fragment(
|
||||||
|
ctx: Context,
|
||||||
|
session: Session,
|
||||||
|
page: Int,
|
||||||
|
can_review: Bool,
|
||||||
|
) -> Response {
|
||||||
let limit = 50
|
let limit = 50
|
||||||
let _offset = page * limit
|
let _offset = page * limit
|
||||||
let result = verifications.list_pending_verifications(ctx, session, limit)
|
let result = verifications.list_pending_verifications(ctx, session, limit)
|
||||||
@@ -170,19 +192,17 @@ pub fn view_fragment(ctx: Context, session: Session, page: Int) -> Response {
|
|||||||
Ok(response) -> {
|
Ok(response) -> {
|
||||||
let fragment =
|
let fragment =
|
||||||
h.div(
|
h.div(
|
||||||
[
|
[a.class("grid gap-4 md:grid-cols-2 xl:grid-cols-3")],
|
||||||
a.attribute("data-review-fragment", "true"),
|
|
||||||
a.attribute("data-page", int.to_string(page)),
|
|
||||||
],
|
|
||||||
list.map(response.pending_verifications, fn(pv) {
|
list.map(response.pending_verifications, fn(pv) {
|
||||||
render_pending_verification_card(ctx, pv)
|
render_pending_verification_card(ctx, pv, can_review)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
wisp.html_response(element.to_document_string(fragment), 200)
|
wisp.html_response(element.to_document_string(fragment), 200)
|
||||||
}
|
}
|
||||||
Error(_) -> {
|
Error(_) -> {
|
||||||
let empty = h.div([a.attribute("data-review-fragment", "true")], [])
|
let empty =
|
||||||
|
h.div([a.class("grid gap-4 md:grid-cols-2 xl:grid-cols-3")], [])
|
||||||
|
|
||||||
wisp.html_response(element.to_document_string(empty), 200)
|
wisp.html_response(element.to_document_string(empty), 200)
|
||||||
}
|
}
|
||||||
@@ -198,6 +218,12 @@ pub fn view_single(
|
|||||||
) -> Response {
|
) -> Response {
|
||||||
let limit = 50
|
let limit = 50
|
||||||
let result = verifications.list_pending_verifications(ctx, session, limit)
|
let result = verifications.list_pending_verifications(ctx, session, limit)
|
||||||
|
let admin_acls = case current_admin {
|
||||||
|
option.Some(admin) -> admin.acls
|
||||||
|
option.None -> []
|
||||||
|
}
|
||||||
|
let can_review =
|
||||||
|
acl.has_permission(admin_acls, constants.acl_pending_verification_review)
|
||||||
|
|
||||||
let content = case result {
|
let content = case result {
|
||||||
Ok(response) -> {
|
Ok(response) -> {
|
||||||
@@ -224,45 +250,13 @@ pub fn view_single(
|
|||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
h.div(
|
h.div(
|
||||||
[a.class("mt-4")],
|
[
|
||||||
list.append(
|
a.class("mt-4 max-w-3xl"),
|
||||||
[review_deck.styles()],
|
a.attribute("data-select-grid", "true"),
|
||||||
list.append(review_deck.script_tags(), [
|
],
|
||||||
h.div(
|
[
|
||||||
[
|
render_pending_verification_card(ctx, pv, can_review),
|
||||||
a.attribute("data-review-deck", "true"),
|
],
|
||||||
a.attribute(
|
|
||||||
"data-empty-url",
|
|
||||||
prepend_base_path(ctx, "/pending-verifications"),
|
|
||||||
),
|
|
||||||
a.tabindex(0),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
h.div([a.class("max-w-2xl mx-auto")], [
|
|
||||||
render_pending_verification_card(ctx, pv),
|
|
||||||
]),
|
|
||||||
h.div(
|
|
||||||
[
|
|
||||||
a.attribute("data-review-progress", "true"),
|
|
||||||
a.class("text-center mt-4 body-sm text-neutral-600"),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
element.text("1 remaining"),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
review_hintbar.view(
|
|
||||||
"←",
|
|
||||||
"Reject",
|
|
||||||
"→",
|
|
||||||
"Approve",
|
|
||||||
"Esc",
|
|
||||||
"Exit",
|
|
||||||
option.Some("Swipe cards on touch devices"),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -289,7 +283,7 @@ pub fn view_single(
|
|||||||
session,
|
session,
|
||||||
current_admin,
|
current_admin,
|
||||||
flash_data,
|
flash_data,
|
||||||
content,
|
h.div([], [content, pending_verifications_script()]),
|
||||||
)
|
)
|
||||||
wisp.html_response(element.to_document_string(html), 200)
|
wisp.html_response(element.to_document_string(html), 200)
|
||||||
}
|
}
|
||||||
@@ -432,23 +426,30 @@ fn api_error_message(err: common.ApiError) -> String {
|
|||||||
fn render_pending_verification_card(
|
fn render_pending_verification_card(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
pv: verifications.PendingVerification,
|
pv: verifications.PendingVerification,
|
||||||
|
can_review: Bool,
|
||||||
) -> element.Element(a) {
|
) -> element.Element(a) {
|
||||||
let metadata_warning = user_agent_warning(pv.metadata)
|
let metadata_warning = user_agent_warning(pv.metadata)
|
||||||
|
let geoip_hint = geoip_reason_value(pv.metadata)
|
||||||
|
|
||||||
h.div(
|
h.div(
|
||||||
[
|
[
|
||||||
a.attribute("data-review-card", "true"),
|
|
||||||
a.attribute(
|
|
||||||
"data-direct-url",
|
|
||||||
prepend_base_path(ctx, "/pending-verifications/" <> pv.user_id),
|
|
||||||
),
|
|
||||||
a.class(
|
a.class(
|
||||||
"bg-white border border-neutral-200 rounded-xl shadow-sm p-6 focus:outline-none focus:ring-2 focus:ring-neutral-900",
|
"bg-white border border-neutral-200 rounded-xl shadow-sm p-6 focus:outline-none focus:ring-2 focus:ring-neutral-900",
|
||||||
),
|
),
|
||||||
a.tabindex(0),
|
a.tabindex(0),
|
||||||
|
a.attribute("data-select-card", pv.user_id),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
h.div([a.class("flex items-start gap-4 mb-6")], [
|
h.div([a.class("flex items-start gap-4 mb-6")], [
|
||||||
|
case can_review {
|
||||||
|
True ->
|
||||||
|
h.input([
|
||||||
|
a.type_("checkbox"),
|
||||||
|
a.class("h-4 w-4 mt-1.5 rounded border-neutral-300"),
|
||||||
|
a.attribute("data-select-checkbox", pv.user_id),
|
||||||
|
])
|
||||||
|
False -> element.none()
|
||||||
|
},
|
||||||
h.img([
|
h.img([
|
||||||
a.src(avatar.get_user_avatar_url(
|
a.src(avatar.get_user_avatar_url(
|
||||||
ctx.media_endpoint,
|
ctx.media_endpoint,
|
||||||
@@ -487,6 +488,16 @@ fn render_pending_verification_card(
|
|||||||
element.text("Registered " <> format_timestamp(pv.created_at)),
|
element.text("Registered " <> format_timestamp(pv.created_at)),
|
||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
|
h.div([a.class("flex flex-col items-end gap-2 ml-auto")], [
|
||||||
|
case metadata_warning {
|
||||||
|
option.Some(msg) -> ui.pill(msg, ui.PillWarning)
|
||||||
|
option.None -> element.none()
|
||||||
|
},
|
||||||
|
case geoip_hint {
|
||||||
|
option.Some(hint) -> ui.pill("GeoIP: " <> hint, ui.PillInfo)
|
||||||
|
option.None -> element.none()
|
||||||
|
},
|
||||||
|
]),
|
||||||
]),
|
]),
|
||||||
h.details(
|
h.details(
|
||||||
[
|
[
|
||||||
@@ -515,63 +526,68 @@ fn render_pending_verification_card(
|
|||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
h.form(
|
case can_review {
|
||||||
[
|
True ->
|
||||||
a.method("post"),
|
h.div([a.class("pt-4 border-t border-neutral-200")], [
|
||||||
action(ctx, "/pending-verifications?action=reject"),
|
h.div([a.class("flex items-center justify-end gap-2 flex-wrap")], [
|
||||||
a.attribute("data-review-submit", "left"),
|
h.form(
|
||||||
a.class("inline-flex w-full"),
|
[
|
||||||
],
|
a.method("post"),
|
||||||
[
|
action(ctx, "/pending-verifications?action=reject"),
|
||||||
h.input([
|
a.attribute("data-async", "true"),
|
||||||
a.type_("hidden"),
|
a.attribute("data-confirm", "Reject this registration?"),
|
||||||
a.name("user_id"),
|
],
|
||||||
a.value(pv.user_id),
|
[
|
||||||
]),
|
h.input([
|
||||||
],
|
a.type_("hidden"),
|
||||||
),
|
a.name("user_id"),
|
||||||
h.form(
|
a.value(pv.user_id),
|
||||||
[
|
]),
|
||||||
a.method("post"),
|
h.button(
|
||||||
action(ctx, "/pending-verifications?action=approve"),
|
[
|
||||||
a.attribute("data-review-submit", "right"),
|
a.type_("submit"),
|
||||||
a.class("inline-flex w-full"),
|
a.attribute("data-action-type", "reject"),
|
||||||
],
|
a.attribute("accesskey", "r"),
|
||||||
[
|
a.class(
|
||||||
h.input([
|
"px-4 py-2 bg-red-600 text-white rounded-lg label hover:bg-red-700 transition-colors",
|
||||||
a.type_("hidden"),
|
),
|
||||||
a.name("user_id"),
|
],
|
||||||
a.value(pv.user_id),
|
[element.text("Reject")],
|
||||||
]),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
h.div(
|
|
||||||
[
|
|
||||||
a.class(
|
|
||||||
"flex items-center justify-between gap-4 pt-4 border-t border-neutral-200",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
h.button(
|
|
||||||
[
|
|
||||||
a.attribute("data-review-action", "left"),
|
|
||||||
a.class(
|
|
||||||
"px-4 py-2 bg-red-600 text-white rounded-lg label hover:bg-red-700 transition-colors",
|
|
||||||
),
|
),
|
||||||
],
|
h.form(
|
||||||
[element.text("Reject")],
|
[
|
||||||
),
|
a.method("post"),
|
||||||
h.button(
|
action(ctx, "/pending-verifications?action=approve"),
|
||||||
[
|
a.attribute("data-async", "true"),
|
||||||
a.attribute("data-review-action", "right"),
|
],
|
||||||
a.class(
|
[
|
||||||
"px-4 py-2 bg-green-600 text-white rounded-lg label hover:bg-green-700 transition-colors",
|
h.input([
|
||||||
|
a.type_("hidden"),
|
||||||
|
a.name("user_id"),
|
||||||
|
a.value(pv.user_id),
|
||||||
|
]),
|
||||||
|
h.button(
|
||||||
|
[
|
||||||
|
a.type_("submit"),
|
||||||
|
a.attribute("data-action-type", "approve"),
|
||||||
|
a.attribute("accesskey", "a"),
|
||||||
|
a.class(
|
||||||
|
"px-4 py-2 bg-green-600 text-white rounded-lg label hover:bg-green-700 transition-colors",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[element.text("Approve")],
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
]),
|
||||||
[element.text("Approve")],
|
])
|
||||||
),
|
False ->
|
||||||
],
|
h.div([a.class("text-sm text-neutral-500 pt-2")], [
|
||||||
),
|
element.text("You need pending_verification:review to act"),
|
||||||
|
])
|
||||||
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -689,6 +705,19 @@ fn metadata_value(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn geoip_reason_value(
|
||||||
|
metadata: List(verifications.PendingVerificationMetadata),
|
||||||
|
) -> option.Option(String) {
|
||||||
|
case metadata_value(metadata, "geoip_reason") {
|
||||||
|
option.Some(reason) ->
|
||||||
|
case reason {
|
||||||
|
"none" -> option.None
|
||||||
|
r -> option.Some(r)
|
||||||
|
}
|
||||||
|
option.None -> option.None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn option_or_default(default: String, value: option.Option(String)) -> String {
|
fn option_or_default(default: String, value: option.Option(String)) -> String {
|
||||||
case value {
|
case value {
|
||||||
option.Some(v) -> v
|
option.Some(v) -> v
|
||||||
@@ -737,6 +766,190 @@ fn empty_state() {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn pending_verifications_script() -> element.Element(a) {
|
||||||
|
let js =
|
||||||
|
"
|
||||||
|
(function () {
|
||||||
|
const grid = document.querySelector('[data-select-grid]');
|
||||||
|
if (!grid) return;
|
||||||
|
const toolbar = document.querySelector('[data-selection-toolbar]');
|
||||||
|
const remainingEl = document.querySelector('[data-remaining-total]');
|
||||||
|
|
||||||
|
const countEl = toolbar?.querySelector('[data-selected-count]') || null;
|
||||||
|
const selectAll = toolbar?.querySelector('[data-select-all]') || null;
|
||||||
|
const bulkButtons = toolbar
|
||||||
|
? Array.from(toolbar.querySelectorAll('[data-bulk-action]'))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
function showToast(message, variant) {
|
||||||
|
const box = document.createElement('div');
|
||||||
|
box.className = 'fixed left-4 right-4 bottom-4 z-50';
|
||||||
|
box.innerHTML =
|
||||||
|
'<div class=\"max-w-xl mx-auto\">' +
|
||||||
|
'<div class=\"px-4 py-3 rounded-lg shadow border ' +
|
||||||
|
(variant === 'success'
|
||||||
|
? 'bg-green-50 border-green-200 text-green-800'
|
||||||
|
: 'bg-red-50 border-red-200 text-red-800') +
|
||||||
|
'\">' +
|
||||||
|
'<div class=\"text-sm font-semibold\">' +
|
||||||
|
(variant === 'success' ? 'Saved' : 'Action failed') +
|
||||||
|
'</div>' +
|
||||||
|
'<div class=\"text-sm mt-1 break-words\">' + (message || 'OK') + '</div>' +
|
||||||
|
'</div></div>';
|
||||||
|
document.body.appendChild(box);
|
||||||
|
setTimeout(() => box.remove(), 4200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRemaining() {
|
||||||
|
if (!remainingEl) return;
|
||||||
|
const total = grid.querySelectorAll('[data-select-card]').length;
|
||||||
|
remainingEl.textContent = total.toString() + ' remaining';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelection() {
|
||||||
|
const boxes = Array.from(grid.querySelectorAll('[data-select-checkbox]'));
|
||||||
|
const selected = boxes.filter((b) => b.checked);
|
||||||
|
if (countEl) countEl.textContent = selected.length + ' selected';
|
||||||
|
bulkButtons.forEach((btn) => (btn.disabled = selected.length === 0));
|
||||||
|
if (selectAll) {
|
||||||
|
selectAll.checked = selected.length > 0 && selected.length === boxes.length;
|
||||||
|
selectAll.indeterminate =
|
||||||
|
selected.length > 0 && selected.length < boxes.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardFor(id) {
|
||||||
|
return grid.querySelector('[data-select-card=\"' + id + '\"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCard(id) {
|
||||||
|
const card = cardFor(id);
|
||||||
|
if (card) card.remove();
|
||||||
|
updateRemaining();
|
||||||
|
updateSelection();
|
||||||
|
if (grid.querySelectorAll('[data-select-card]').length === 0) {
|
||||||
|
grid.innerHTML =
|
||||||
|
'<div class=\"col-span-full border border-dashed border-neutral-200 rounded-lg p-8 text-center text-neutral-500\">All registration requests have been processed</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setButtonLoading(btn, loading) {
|
||||||
|
if (!btn) return;
|
||||||
|
btn.disabled = loading;
|
||||||
|
if (loading) {
|
||||||
|
btn.dataset.originalText = btn.textContent;
|
||||||
|
btn.textContent = 'Working…';
|
||||||
|
} else if (btn.dataset.originalText) {
|
||||||
|
btn.textContent = btn.dataset.originalText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitForm(form) {
|
||||||
|
const actionUrl = new URL(form.action, window.location.origin);
|
||||||
|
actionUrl.searchParams.set('background', '1');
|
||||||
|
const fd = new FormData(form);
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
fd.forEach((v, k) => body.append(k, v));
|
||||||
|
const resp = await fetch(actionUrl.toString(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/x-www-form-urlencoded;charset=UTF-8' },
|
||||||
|
body: body.toString(),
|
||||||
|
credentials: 'same-origin',
|
||||||
|
});
|
||||||
|
if (!resp.ok && resp.status !== 204) {
|
||||||
|
let txt = '';
|
||||||
|
try { txt = await resp.text(); } catch (_) {}
|
||||||
|
throw new Error(txt || 'Request failed (' + resp.status + ')');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function actOn(id, action) {
|
||||||
|
const form = grid.querySelector(
|
||||||
|
'[data-select-card=\"' + id + '\"] form[data-action-type=\"' + action + '\"]'
|
||||||
|
);
|
||||||
|
if (!form) throw new Error('Missing form for ' + action);
|
||||||
|
await submitForm(form);
|
||||||
|
removeCard(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBulk(action) {
|
||||||
|
const boxes = Array.from(grid.querySelectorAll('[data-select-checkbox]:checked'));
|
||||||
|
if (boxes.length === 0) return;
|
||||||
|
const confirmMsg =
|
||||||
|
action === 'reject'
|
||||||
|
? 'Reject ' + boxes.length + ' registration(s)?'
|
||||||
|
: 'Approve ' + boxes.length + ' registration(s)?';
|
||||||
|
if (!window.confirm(confirmMsg)) return;
|
||||||
|
|
||||||
|
const button = toolbar.querySelector('[data-bulk-action=\"' + action + '\"]');
|
||||||
|
setButtonLoading(button, true);
|
||||||
|
try {
|
||||||
|
for (const box of boxes) {
|
||||||
|
const id = box.getAttribute('data-select-checkbox');
|
||||||
|
if (!id) continue;
|
||||||
|
await actOn(id, action);
|
||||||
|
}
|
||||||
|
showToast('Completed ' + action + ' for ' + boxes.length + ' item(s)', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err && err.message ? err.message : String(err), 'error');
|
||||||
|
} finally {
|
||||||
|
setButtonLoading(button, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bulkButtons.forEach((btn) => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const action = btn.getAttribute('data-bulk-action');
|
||||||
|
if (action) handleBulk(action);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selectAll) {
|
||||||
|
selectAll.addEventListener('change', (e) => {
|
||||||
|
const boxes = grid.querySelectorAll('[data-select-checkbox]');
|
||||||
|
boxes.forEach((b) => (b.checked = e.target.checked));
|
||||||
|
updateSelection();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
grid.addEventListener('change', (e) => {
|
||||||
|
const target = e.target;
|
||||||
|
if (target && target.matches('[data-select-checkbox]')) {
|
||||||
|
updateSelection();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('form[data-async]').forEach((form) => {
|
||||||
|
form.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const confirmMsg = form.getAttribute('data-confirm');
|
||||||
|
if (confirmMsg && !window.confirm(confirmMsg)) return;
|
||||||
|
const btn = form.querySelector('button[type=\"submit\"]');
|
||||||
|
const id = form.querySelector('[name=\"user_id\"]')?.value;
|
||||||
|
const action = btn?.getAttribute('data-action-type') || 'action';
|
||||||
|
setButtonLoading(btn, true);
|
||||||
|
submitForm(form)
|
||||||
|
.then(() => {
|
||||||
|
if (id) removeCard(id);
|
||||||
|
showToast(
|
||||||
|
(action === 'approve' ? 'Approved ' : 'Rejected ') + (id || 'item'),
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((err) => showToast(err && err.message ? err.message : String(err), 'error'))
|
||||||
|
.finally(() => setButtonLoading(btn, false));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
updateRemaining();
|
||||||
|
updateSelection();
|
||||||
|
})();
|
||||||
|
"
|
||||||
|
|
||||||
|
h.script([a.attribute("defer", "defer")], js)
|
||||||
|
}
|
||||||
|
|
||||||
fn error_view(err: common.ApiError) {
|
fn error_view(err: common.ApiError) {
|
||||||
let #(title, message) = case err {
|
let #(title, message) = case err {
|
||||||
common.Unauthorized -> #(
|
common.Unauthorized -> #(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -121,85 +121,99 @@ pub fn view(
|
|||||||
h.div(
|
h.div(
|
||||||
[a.class("bg-white border border-neutral-200 rounded-lg p-6 mb-6")],
|
[a.class("bg-white border border-neutral-200 rounded-lg p-6 mb-6")],
|
||||||
[
|
[
|
||||||
h.div([a.class("flex items-start gap-6")], [
|
h.div(
|
||||||
h.div([a.class("flex-shrink-0")], [
|
[
|
||||||
h.img([
|
a.class(
|
||||||
a.src(avatar.get_user_avatar_url(
|
"flex flex-col gap-4 sm:flex-row sm:items-start sm:gap-6",
|
||||||
ctx.media_endpoint,
|
),
|
||||||
ctx.cdn_endpoint,
|
],
|
||||||
user_data.id,
|
[
|
||||||
user_data.avatar,
|
h.div(
|
||||||
True,
|
[
|
||||||
ctx.asset_version,
|
a.class(
|
||||||
)),
|
"flex-shrink-0 flex items-center sm:block justify-center",
|
||||||
a.alt(user_data.username),
|
),
|
||||||
a.class("w-24 h-24 rounded-full"),
|
],
|
||||||
]),
|
[
|
||||||
]),
|
h.img([
|
||||||
h.div([a.class("flex-1")], [
|
a.src(avatar.get_user_avatar_url(
|
||||||
h.div([a.class("flex items-center gap-3 mb-3")], [
|
ctx.media_endpoint,
|
||||||
ui.heading_section(
|
ctx.cdn_endpoint,
|
||||||
user_data.username
|
user_data.id,
|
||||||
<> "#"
|
user_data.avatar,
|
||||||
<> user.format_discriminator(user_data.discriminator),
|
True,
|
||||||
),
|
ctx.asset_version,
|
||||||
case user_data.bot {
|
)),
|
||||||
True ->
|
a.alt(user_data.username),
|
||||||
h.span(
|
a.class("w-24 h-24 rounded-full"),
|
||||||
[
|
|
||||||
a.class(
|
|
||||||
"px-2 py-1 bg-blue-100 text-blue-700 text-sm font-medium rounded uppercase",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
[element.text("Bot")],
|
|
||||||
)
|
|
||||||
False -> element.none()
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
case list.is_empty(badges) {
|
|
||||||
False ->
|
|
||||||
h.div(
|
|
||||||
[a.class("flex items-center gap-2 mb-3")],
|
|
||||||
list.map(badges, fn(b) {
|
|
||||||
h.img([
|
|
||||||
a.src(b.icon),
|
|
||||||
a.alt(b.name),
|
|
||||||
a.title(b.name),
|
|
||||||
a.class("w-6 h-6"),
|
|
||||||
])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
True -> element.none()
|
|
||||||
},
|
|
||||||
h.div([a.class("flex flex-wrap items-start gap-4")], [
|
|
||||||
h.div([a.class("flex items-start gap-2")], [
|
|
||||||
h.div([a.class("text-sm font-medium text-neutral-600")], [
|
|
||||||
element.text("User ID:"),
|
|
||||||
]),
|
|
||||||
h.div([a.class("text-sm text-neutral-900")], [
|
|
||||||
element.text(user_data.id),
|
|
||||||
]),
|
]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
h.div([a.class("flex-1")], [
|
||||||
|
h.div([a.class("flex flex-wrap items-center gap-3 mb-3")], [
|
||||||
|
ui.heading_section(
|
||||||
|
user_data.username
|
||||||
|
<> "#"
|
||||||
|
<> user.format_discriminator(user_data.discriminator),
|
||||||
|
),
|
||||||
|
case user_data.bot {
|
||||||
|
True ->
|
||||||
|
h.span(
|
||||||
|
[
|
||||||
|
a.class(
|
||||||
|
"px-2 py-1 bg-blue-100 text-blue-700 text-sm font-medium rounded uppercase",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[element.text("Bot")],
|
||||||
|
)
|
||||||
|
False -> element.none()
|
||||||
|
},
|
||||||
]),
|
]),
|
||||||
case user.extract_timestamp(user_data.id) {
|
case list.is_empty(badges) {
|
||||||
Ok(created_at) ->
|
False ->
|
||||||
h.div([a.class("flex items-start gap-2")], [
|
h.div(
|
||||||
h.div(
|
[a.class("flex items-center gap-2 mb-3 flex-wrap")],
|
||||||
[
|
list.map(badges, fn(b) {
|
||||||
a.class("text-sm font-medium text-neutral-600"),
|
h.img([
|
||||||
],
|
a.src(b.icon),
|
||||||
[
|
a.alt(b.name),
|
||||||
element.text("Created:"),
|
a.title(b.name),
|
||||||
],
|
a.class("w-6 h-6"),
|
||||||
),
|
])
|
||||||
h.div([a.class("text-sm text-neutral-900")], [
|
}),
|
||||||
element.text(created_at),
|
)
|
||||||
]),
|
True -> element.none()
|
||||||
])
|
|
||||||
Error(_) -> element.none()
|
|
||||||
},
|
},
|
||||||
|
h.div([a.class("flex flex-wrap items-start gap-4")], [
|
||||||
|
h.div([a.class("flex items-start gap-2 min-w-0")], [
|
||||||
|
h.div([a.class("text-sm font-medium text-neutral-600")], [
|
||||||
|
element.text("User ID:"),
|
||||||
|
]),
|
||||||
|
h.div([a.class("text-sm text-neutral-900 break-all")], [
|
||||||
|
element.text(user_data.id),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
case user.extract_timestamp(user_data.id) {
|
||||||
|
Ok(created_at) ->
|
||||||
|
h.div([a.class("flex items-start gap-2")], [
|
||||||
|
h.div(
|
||||||
|
[
|
||||||
|
a.class("text-sm font-medium text-neutral-600"),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
element.text("Created:"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
h.div([a.class("text-sm text-neutral-900")], [
|
||||||
|
element.text(created_at),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
Error(_) -> element.none()
|
||||||
|
},
|
||||||
|
]),
|
||||||
]),
|
]),
|
||||||
]),
|
],
|
||||||
]),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
render_tabs(
|
render_tabs(
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ pub fn view(
|
|||||||
fn render_search_form(ctx: Context, query: option.Option(String)) {
|
fn render_search_form(ctx: Context, query: option.Option(String)) {
|
||||||
ui.card(ui.PaddingSmall, [
|
ui.card(ui.PaddingSmall, [
|
||||||
h.form([a.method("get"), a.class("flex flex-col gap-4")], [
|
h.form([a.method("get"), a.class("flex flex-col gap-4")], [
|
||||||
h.div([a.class("flex gap-2")], [
|
h.div([a.class("flex flex-col sm:flex-row gap-2")], [
|
||||||
h.input([
|
h.input([
|
||||||
a.type_("text"),
|
a.type_("text"),
|
||||||
a.name("q"),
|
a.name("q"),
|
||||||
@@ -125,12 +125,12 @@ fn render_search_form(ctx: Context, query: option.Option(String)) {
|
|||||||
),
|
),
|
||||||
a.attribute("autocomplete", "off"),
|
a.attribute("autocomplete", "off"),
|
||||||
]),
|
]),
|
||||||
ui.button_primary("Search", "submit", []),
|
ui.button_primary("Search", "submit", [a.class("w-full sm:w-auto")]),
|
||||||
h.a(
|
h.a(
|
||||||
[
|
[
|
||||||
href(ctx, "/users"),
|
href(ctx, "/users"),
|
||||||
a.class(
|
a.class(
|
||||||
"px-4 py-2 bg-white text-neutral-700 border border-neutral-300 rounded-lg text-sm font-medium hover:bg-neutral-50 transition-colors",
|
"px-4 py-2 bg-white text-neutral-700 border border-neutral-300 rounded-lg text-sm font-medium hover:bg-neutral-50 transition-colors w-full sm:w-auto text-center",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
[element.text("Clear")],
|
[element.text("Clear")],
|
||||||
|
|||||||
@@ -161,6 +161,19 @@ fn get_bool_query(req: Request, key: String) -> Bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn clamp_limit(limit: Int) -> Int {
|
||||||
|
let min = 10
|
||||||
|
let max = 200
|
||||||
|
case limit < min {
|
||||||
|
True -> min
|
||||||
|
False ->
|
||||||
|
case limit > max {
|
||||||
|
True -> max
|
||||||
|
False -> limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn api_error_message(err: common.ApiError) -> String {
|
fn api_error_message(err: common.ApiError) -> String {
|
||||||
case err {
|
case err {
|
||||||
common.Unauthorized -> "Unauthorized"
|
common.Unauthorized -> "Unauthorized"
|
||||||
@@ -897,27 +910,71 @@ fn handle_authenticated_request(req: Request, ctx: Context) -> Response {
|
|||||||
Get -> {
|
Get -> {
|
||||||
use user_session, current_admin <- with_session_and_admin(req, ctx)
|
use user_session, current_admin <- with_session_and_admin(req, ctx)
|
||||||
let flash_data = flash.from_request(req)
|
let flash_data = flash.from_request(req)
|
||||||
|
let admin_acls = case current_admin {
|
||||||
|
option.Some(admin) -> admin.acls
|
||||||
|
_ -> []
|
||||||
|
}
|
||||||
|
|
||||||
pending_verifications_page.view(
|
case
|
||||||
ctx,
|
acl.has_permission(
|
||||||
user_session,
|
admin_acls,
|
||||||
current_admin,
|
constants.acl_pending_verification_view,
|
||||||
flash_data,
|
)
|
||||||
)
|
{
|
||||||
|
True ->
|
||||||
|
pending_verifications_page.view(
|
||||||
|
ctx,
|
||||||
|
user_session,
|
||||||
|
current_admin,
|
||||||
|
flash_data,
|
||||||
|
)
|
||||||
|
False ->
|
||||||
|
wisp.response(403)
|
||||||
|
|> wisp.string_body(
|
||||||
|
"Forbidden: requires pending_verification:view permission",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Post -> {
|
Post -> {
|
||||||
use user_session <- with_session(req, ctx)
|
use user_session <- with_session(req, ctx)
|
||||||
let query = wisp.get_query(req)
|
let query = wisp.get_query(req)
|
||||||
let action = list.key_find(query, "action") |> option.from_result
|
let action = list.key_find(query, "action") |> option.from_result
|
||||||
let background = get_bool_query(req, "background")
|
let background = get_bool_query(req, "background")
|
||||||
|
let admin_result = users.get_current_admin(ctx, user_session)
|
||||||
|
let admin_acls = case admin_result {
|
||||||
|
Ok(option.Some(admin)) -> admin.acls
|
||||||
|
_ -> []
|
||||||
|
}
|
||||||
|
|
||||||
pending_verifications_page.handle_action(
|
case
|
||||||
req,
|
acl.has_permission(
|
||||||
ctx,
|
admin_acls,
|
||||||
user_session,
|
constants.acl_pending_verification_review,
|
||||||
action,
|
)
|
||||||
background,
|
{
|
||||||
)
|
True ->
|
||||||
|
pending_verifications_page.handle_action(
|
||||||
|
req,
|
||||||
|
ctx,
|
||||||
|
user_session,
|
||||||
|
action,
|
||||||
|
background,
|
||||||
|
)
|
||||||
|
False ->
|
||||||
|
case background {
|
||||||
|
True ->
|
||||||
|
wisp.json_response(
|
||||||
|
"{\"error\": \"Forbidden: requires pending_verification:review permission\"}",
|
||||||
|
403,
|
||||||
|
)
|
||||||
|
False ->
|
||||||
|
flash.redirect_with_error(
|
||||||
|
ctx,
|
||||||
|
"/pending-verifications",
|
||||||
|
"Forbidden: requires pending_verification:review permission",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ -> wisp.method_not_allowed([Get, Post])
|
_ -> wisp.method_not_allowed([Get, Post])
|
||||||
}
|
}
|
||||||
@@ -932,8 +989,32 @@ fn handle_authenticated_request(req: Request, ctx: Context) -> Response {
|
|||||||
|> option.unwrap("1")
|
|> option.unwrap("1")
|
||||||
let page =
|
let page =
|
||||||
int.parse(page_str) |> option.from_result |> option.unwrap(1)
|
int.parse(page_str) |> option.from_result |> option.unwrap(1)
|
||||||
|
let admin_result = users.get_current_admin(ctx, user_session)
|
||||||
|
let admin_acls = case admin_result {
|
||||||
|
Ok(option.Some(admin)) -> admin.acls
|
||||||
|
_ -> []
|
||||||
|
}
|
||||||
|
let can_review =
|
||||||
|
acl.has_permission(
|
||||||
|
admin_acls,
|
||||||
|
constants.acl_pending_verification_review,
|
||||||
|
)
|
||||||
|
|
||||||
pending_verifications_page.view_fragment(ctx, user_session, page)
|
case
|
||||||
|
acl.has_permission(
|
||||||
|
admin_acls,
|
||||||
|
constants.acl_pending_verification_view,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
True ->
|
||||||
|
pending_verifications_page.view_fragment(
|
||||||
|
ctx,
|
||||||
|
user_session,
|
||||||
|
page,
|
||||||
|
can_review,
|
||||||
|
)
|
||||||
|
False -> wisp.response(403) |> wisp.string_body("Forbidden")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ -> wisp.method_not_allowed([Get])
|
_ -> wisp.method_not_allowed([Get])
|
||||||
}
|
}
|
||||||
@@ -985,34 +1066,39 @@ fn handle_authenticated_request(req: Request, ctx: Context) -> Response {
|
|||||||
|> option.unwrap("0")
|
|> option.unwrap("0")
|
||||||
let page =
|
let page =
|
||||||
int.parse(page_str) |> option.from_result |> option.unwrap(0)
|
int.parse(page_str) |> option.from_result |> option.unwrap(0)
|
||||||
let fragment = get_bool_query(req, "fragment")
|
let sort =
|
||||||
let table_view = get_bool_query(req, "view")
|
list.key_find(query, "sort")
|
||||||
|
|> option.from_result
|
||||||
|
|> option.map(fn(s) {
|
||||||
|
case string.trim(s) {
|
||||||
|
"" -> option.None
|
||||||
|
v -> option.Some(v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|> option.unwrap(option.None)
|
||||||
|
let limit_str =
|
||||||
|
list.key_find(query, "limit")
|
||||||
|
|> option.from_result
|
||||||
|
|> option.unwrap("50")
|
||||||
|
let limit =
|
||||||
|
int.parse(limit_str)
|
||||||
|
|> option.from_result
|
||||||
|
|> option.unwrap(50)
|
||||||
|
|> clamp_limit
|
||||||
|
|
||||||
case fragment {
|
reports_page.view_with_mode(
|
||||||
True ->
|
ctx,
|
||||||
reports_page.view_fragment(
|
user_session,
|
||||||
ctx,
|
current_admin,
|
||||||
user_session,
|
flash_data,
|
||||||
search_query,
|
search_query,
|
||||||
status_filter,
|
status_filter,
|
||||||
type_filter,
|
type_filter,
|
||||||
category_filter,
|
category_filter,
|
||||||
page,
|
page,
|
||||||
)
|
limit,
|
||||||
False ->
|
sort,
|
||||||
reports_page.view_with_mode(
|
)
|
||||||
ctx,
|
|
||||||
user_session,
|
|
||||||
current_admin,
|
|
||||||
flash_data,
|
|
||||||
search_query,
|
|
||||||
status_filter,
|
|
||||||
type_filter,
|
|
||||||
category_filter,
|
|
||||||
page,
|
|
||||||
table_view,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_ -> wisp.method_not_allowed([Get])
|
_ -> wisp.method_not_allowed([Get])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export const VerificationAdminController = (app: HonoApp) => {
|
|||||||
app.post(
|
app.post(
|
||||||
'/admin/pending-verifications/list',
|
'/admin/pending-verifications/list',
|
||||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||||
requireAdminACL(AdminACLs.USER_LOOKUP),
|
requireAdminACL(AdminACLs.PENDING_VERIFICATION_VIEW),
|
||||||
Validator('json', z.object({limit: z.number().default(100)})),
|
Validator('json', z.object({limit: z.number().default(100)})),
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const adminService = ctx.get('adminService');
|
const adminService = ctx.get('adminService');
|
||||||
@@ -42,7 +42,7 @@ export const VerificationAdminController = (app: HonoApp) => {
|
|||||||
app.post(
|
app.post(
|
||||||
'/admin/pending-verifications/approve',
|
'/admin/pending-verifications/approve',
|
||||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||||
requireAdminACL(AdminACLs.USER_UPDATE_FLAGS),
|
requireAdminACL(AdminACLs.PENDING_VERIFICATION_REVIEW),
|
||||||
Validator('json', z.object({user_id: Int64Type})),
|
Validator('json', z.object({user_id: Int64Type})),
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const adminService = ctx.get('adminService');
|
const adminService = ctx.get('adminService');
|
||||||
@@ -56,7 +56,7 @@ export const VerificationAdminController = (app: HonoApp) => {
|
|||||||
app.post(
|
app.post(
|
||||||
'/admin/pending-verifications/reject',
|
'/admin/pending-verifications/reject',
|
||||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||||
requireAdminACL(AdminACLs.USER_UPDATE_FLAGS),
|
requireAdminACL(AdminACLs.PENDING_VERIFICATION_REVIEW),
|
||||||
Validator('json', z.object({user_id: Int64Type})),
|
Validator('json', z.object({user_id: Int64Type})),
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const adminService = ctx.get('adminService');
|
const adminService = ctx.get('adminService');
|
||||||
@@ -70,7 +70,7 @@ export const VerificationAdminController = (app: HonoApp) => {
|
|||||||
app.post(
|
app.post(
|
||||||
'/admin/pending-verifications/bulk-approve',
|
'/admin/pending-verifications/bulk-approve',
|
||||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||||
requireAdminACL(AdminACLs.USER_UPDATE_FLAGS),
|
requireAdminACL(AdminACLs.PENDING_VERIFICATION_REVIEW),
|
||||||
Validator('json', z.object({user_ids: z.array(Int64Type).min(1)})),
|
Validator('json', z.object({user_ids: z.array(Int64Type).min(1)})),
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const adminService = ctx.get('adminService');
|
const adminService = ctx.get('adminService');
|
||||||
@@ -85,7 +85,7 @@ export const VerificationAdminController = (app: HonoApp) => {
|
|||||||
app.post(
|
app.post(
|
||||||
'/admin/pending-verifications/bulk-reject',
|
'/admin/pending-verifications/bulk-reject',
|
||||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||||
requireAdminACL(AdminACLs.USER_UPDATE_FLAGS),
|
requireAdminACL(AdminACLs.PENDING_VERIFICATION_REVIEW),
|
||||||
Validator('json', z.object({user_ids: z.array(Int64Type).min(1)})),
|
Validator('json', z.object({user_ids: z.array(Int64Type).min(1)})),
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const adminService = ctx.get('adminService');
|
const adminService = ctx.get('adminService');
|
||||||
|
|||||||
@@ -165,7 +165,13 @@ export class AuthService implements IAuthService {
|
|||||||
botMfaMirrorService?: BotMfaMirrorService,
|
botMfaMirrorService?: BotMfaMirrorService,
|
||||||
authMfaService?: AuthMfaService,
|
authMfaService?: AuthMfaService,
|
||||||
) {
|
) {
|
||||||
this.utilityService = new AuthUtilityService(repository, rateLimitService, gatewayService);
|
this.utilityService = new AuthUtilityService(
|
||||||
|
repository,
|
||||||
|
rateLimitService,
|
||||||
|
gatewayService,
|
||||||
|
inviteService,
|
||||||
|
pendingJoinInviteStore,
|
||||||
|
);
|
||||||
|
|
||||||
this.sessionService = new AuthSessionService(
|
this.sessionService = new AuthSessionService(
|
||||||
repository,
|
repository,
|
||||||
|
|||||||
@@ -19,14 +19,17 @@
|
|||||||
|
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import {promisify} from 'node:util';
|
import {promisify} from 'node:util';
|
||||||
import type {UserID} from '~/BrandedTypes';
|
import {createInviteCode, type UserID} from '~/BrandedTypes';
|
||||||
import {APIErrorCodes, UserFlags} from '~/Constants';
|
import {APIErrorCodes, UserFlags} from '~/Constants';
|
||||||
import {AccessDeniedError, FluxerAPIError, InputValidationError, UnauthorizedError} from '~/Errors';
|
import {AccessDeniedError, FluxerAPIError, InputValidationError, UnauthorizedError} from '~/Errors';
|
||||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||||
import type {IRateLimitService} from '~/infrastructure/IRateLimitService';
|
import type {IRateLimitService} from '~/infrastructure/IRateLimitService';
|
||||||
|
import type {PendingJoinInviteStore} from '~/infrastructure/PendingJoinInviteStore';
|
||||||
|
import type {InviteService} from '~/invite/InviteService';
|
||||||
import {Logger} from '~/Logger';
|
import {Logger} from '~/Logger';
|
||||||
import {getUserSearchService} from '~/Meilisearch';
|
import {getUserSearchService} from '~/Meilisearch';
|
||||||
import type {User} from '~/Models';
|
import type {User} from '~/Models';
|
||||||
|
import {createRequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||||
import type {IUserRepository} from '~/user/IUserRepository';
|
import type {IUserRepository} from '~/user/IUserRepository';
|
||||||
import {mapUserToPrivateResponse} from '~/user/UserModel';
|
import {mapUserToPrivateResponse} from '~/user/UserModel';
|
||||||
import * as AgeUtils from '~/utils/AgeUtils';
|
import * as AgeUtils from '~/utils/AgeUtils';
|
||||||
@@ -61,6 +64,8 @@ export class AuthUtilityService {
|
|||||||
private repository: IUserRepository,
|
private repository: IUserRepository,
|
||||||
private rateLimitService: IRateLimitService,
|
private rateLimitService: IRateLimitService,
|
||||||
private gatewayService: IGatewayService,
|
private gatewayService: IGatewayService,
|
||||||
|
private inviteService: InviteService,
|
||||||
|
private pendingJoinInviteStore: PendingJoinInviteStore,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async generateSecureToken(length = 64): Promise<string> {
|
async generateSecureToken(length = 64): Promise<string> {
|
||||||
@@ -210,5 +215,29 @@ export class AuthUtilityService {
|
|||||||
event: 'USER_UPDATE',
|
event: 'USER_UPDATE',
|
||||||
data: mapUserToPrivateResponse(updatedUser!),
|
data: mapUserToPrivateResponse(updatedUser!),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.autoJoinPendingInvite(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async autoJoinPendingInvite(userId: UserID): Promise<void> {
|
||||||
|
const pendingInviteCode = await this.pendingJoinInviteStore.getPendingInvite(userId);
|
||||||
|
if (!pendingInviteCode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.inviteService.acceptInvite({
|
||||||
|
userId,
|
||||||
|
inviteCode: createInviteCode(pendingInviteCode),
|
||||||
|
requestCache: createRequestCache(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
Logger.warn(
|
||||||
|
{userId, inviteCode: pendingInviteCode, error},
|
||||||
|
'Failed to auto-join invite after redeeming beta code',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await this.pendingJoinInviteStore.deletePendingInvite(userId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,6 +174,11 @@ export abstract class BaseChannelAuthService {
|
|||||||
async validateDMSendPermissions({channelId, userId}: {channelId: ChannelID; userId: UserID}): Promise<void> {
|
async validateDMSendPermissions({channelId, userId}: {channelId: ChannelID; userId: UserID}): Promise<void> {
|
||||||
const channel = await this.channelRepository.channelData.findUnique(channelId);
|
const channel = await this.channelRepository.channelData.findUnique(channelId);
|
||||||
if (!channel) throw new UnknownChannelError();
|
if (!channel) throw new UnknownChannelError();
|
||||||
|
|
||||||
|
if (channel.type === ChannelTypes.GROUP_DM || channel.type === ChannelTypes.DM_PERSONAL_NOTES) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const recipients = await this.userRepository.listUsers(Array.from(channel.recipientIds));
|
const recipients = await this.userRepository.listUsers(Array.from(channel.recipientIds));
|
||||||
await this.dmPermissionValidator.validate({recipients, userId});
|
await this.dmPermissionValidator.validate({recipients, userId});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,12 @@ export class CallService {
|
|||||||
throw new InvalidChannelTypeForCallError();
|
throw new InvalidChannelTypeForCallError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const caller = await this.userRepository.findUnique(userId);
|
||||||
|
const isUnclaimedCaller = caller != null && !caller.passwordHash && !caller.isBot;
|
||||||
|
if (isUnclaimedCaller && channel.type === ChannelTypes.DM) {
|
||||||
|
return {ringable: false};
|
||||||
|
}
|
||||||
|
|
||||||
const call = await this.gatewayService.getCall(channelId);
|
const call = await this.gatewayService.getCall(channelId);
|
||||||
const alreadyInCall = call ? call.voice_states.some((vs) => vs.user_id === userId.toString()) : false;
|
const alreadyInCall = call ? call.voice_states.some((vs) => vs.user_id === userId.toString()) : false;
|
||||||
if (alreadyInCall) {
|
if (alreadyInCall) {
|
||||||
|
|||||||
@@ -217,6 +217,8 @@ export const AdminACLs = {
|
|||||||
USER_DISABLE_SUSPICIOUS: 'user:disable:suspicious',
|
USER_DISABLE_SUSPICIOUS: 'user:disable:suspicious',
|
||||||
USER_DELETE: 'user:delete',
|
USER_DELETE: 'user:delete',
|
||||||
USER_CANCEL_BULK_MESSAGE_DELETION: 'user:cancel:bulk_message_deletion',
|
USER_CANCEL_BULK_MESSAGE_DELETION: 'user:cancel:bulk_message_deletion',
|
||||||
|
PENDING_VERIFICATION_VIEW: 'pending_verification:view',
|
||||||
|
PENDING_VERIFICATION_REVIEW: 'pending_verification:review',
|
||||||
BETA_CODES_GENERATE: 'beta_codes:generate',
|
BETA_CODES_GENERATE: 'beta_codes:generate',
|
||||||
GIFT_CODES_GENERATE: 'gift_codes:generate',
|
GIFT_CODES_GENERATE: 'gift_codes:generate',
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,12 @@ import {applicationIdToUserId} from '~/BrandedTypes';
|
|||||||
import {UserFlags, UserPremiumTypes} from '~/Constants';
|
import {UserFlags, UserPremiumTypes} from '~/Constants';
|
||||||
import type {UserRow} from '~/database/CassandraTypes';
|
import type {UserRow} from '~/database/CassandraTypes';
|
||||||
import type {ApplicationRow} from '~/database/types/OAuth2Types';
|
import type {ApplicationRow} from '~/database/types/OAuth2Types';
|
||||||
import {BotUserNotFoundError, InputValidationError, UnknownApplicationError} from '~/Errors';
|
import {
|
||||||
|
BotUserNotFoundError,
|
||||||
|
InputValidationError,
|
||||||
|
UnclaimedAccountRestrictedError,
|
||||||
|
UnknownApplicationError,
|
||||||
|
} from '~/Errors';
|
||||||
import type {DiscriminatorService} from '~/infrastructure/DiscriminatorService';
|
import type {DiscriminatorService} from '~/infrastructure/DiscriminatorService';
|
||||||
import type {EntityAssetService, PreparedAssetUpload} from '~/infrastructure/EntityAssetService';
|
import type {EntityAssetService, PreparedAssetUpload} from '~/infrastructure/EntityAssetService';
|
||||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||||
@@ -130,6 +135,10 @@ export class ApplicationService {
|
|||||||
const owner = await this.deps.userRepository.findUniqueAssert(args.ownerUserId);
|
const owner = await this.deps.userRepository.findUniqueAssert(args.ownerUserId);
|
||||||
const botIsPublic = args.botPublic ?? true;
|
const botIsPublic = args.botPublic ?? true;
|
||||||
|
|
||||||
|
if (!owner.passwordHash && !owner.isBot) {
|
||||||
|
throw new UnclaimedAccountRestrictedError('create applications');
|
||||||
|
}
|
||||||
|
|
||||||
const applicationId: ApplicationID = this.deps.snowflakeService.generate() as ApplicationID;
|
const applicationId: ApplicationID = this.deps.snowflakeService.generate() as ApplicationID;
|
||||||
const botUserId = applicationIdToUserId(applicationId);
|
const botUserId = applicationIdToUserId(applicationId);
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,13 @@ import type Stripe from 'stripe';
|
|||||||
import type {UserID} from '~/BrandedTypes';
|
import type {UserID} from '~/BrandedTypes';
|
||||||
import {Config} from '~/Config';
|
import {Config} from '~/Config';
|
||||||
import {UserFlags, UserPremiumTypes} from '~/Constants';
|
import {UserFlags, UserPremiumTypes} from '~/Constants';
|
||||||
import {NoVisionarySlotsAvailableError, PremiumPurchaseBlockedError, StripeError, UnknownUserError} from '~/Errors';
|
import {
|
||||||
|
NoVisionarySlotsAvailableError,
|
||||||
|
PremiumPurchaseBlockedError,
|
||||||
|
StripeError,
|
||||||
|
UnclaimedAccountRestrictedError,
|
||||||
|
UnknownUserError,
|
||||||
|
} from '~/Errors';
|
||||||
import {Logger} from '~/Logger';
|
import {Logger} from '~/Logger';
|
||||||
import type {User} from '~/Models';
|
import type {User} from '~/Models';
|
||||||
import type {IUserRepository} from '~/user/IUserRepository';
|
import type {IUserRepository} from '~/user/IUserRepository';
|
||||||
@@ -212,6 +218,10 @@ export class StripeCheckoutService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
validateUserCanPurchase(user: User): void {
|
validateUserCanPurchase(user: User): void {
|
||||||
|
if (!user.passwordHash && !user.isBot) {
|
||||||
|
throw new UnclaimedAccountRestrictedError('make purchases');
|
||||||
|
}
|
||||||
|
|
||||||
if (user.flags & UserFlags.PREMIUM_PURCHASE_DISABLED) {
|
if (user.flags & UserFlags.PREMIUM_PURCHASE_DISABLED) {
|
||||||
throw new PremiumPurchaseBlockedError();
|
throw new PremiumPurchaseBlockedError();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,6 +205,8 @@ export const UserAccountController = (app: HonoApp) => {
|
|||||||
const emailTokenProvided = emailToken !== undefined;
|
const emailTokenProvided = emailToken !== undefined;
|
||||||
const isUnclaimed = !user.passwordHash;
|
const isUnclaimed = !user.passwordHash;
|
||||||
if (isUnclaimed) {
|
if (isUnclaimed) {
|
||||||
|
const {username: _ignoredUsername, discriminator: _ignoredDiscriminator, ...rest} = userUpdateData;
|
||||||
|
userUpdateData = rest;
|
||||||
const allowed = new Set(['new_password']);
|
const allowed = new Set(['new_password']);
|
||||||
const disallowedField = Object.keys(userUpdateData).find((key) => !allowed.has(key));
|
const disallowedField = Object.keys(userUpdateData).find((key) => !allowed.has(key));
|
||||||
if (disallowedField) {
|
if (disallowedField) {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
HarvestOnCooldownError,
|
HarvestOnCooldownError,
|
||||||
MaxBookmarksError,
|
MaxBookmarksError,
|
||||||
MissingPermissionsError,
|
MissingPermissionsError,
|
||||||
|
UnclaimedAccountRestrictedError,
|
||||||
UnknownChannelError,
|
UnknownChannelError,
|
||||||
UnknownHarvestError,
|
UnknownHarvestError,
|
||||||
UnknownMessageError,
|
UnknownMessageError,
|
||||||
@@ -120,6 +121,10 @@ export class UserContentService {
|
|||||||
throw new UnknownUserError();
|
throw new UnknownUserError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!user.passwordHash && !user.isBot) {
|
||||||
|
throw new UnclaimedAccountRestrictedError('create beta codes');
|
||||||
|
}
|
||||||
|
|
||||||
const existingBetaCodes = await this.userContentRepository.listBetaCodes(userId);
|
const existingBetaCodes = await this.userContentRepository.listBetaCodes(userId);
|
||||||
const unclaimedCount = existingBetaCodes.filter((code) => !code.redeemerId).length;
|
const unclaimedCount = existingBetaCodes.filter((code) => !code.redeemerId).length;
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type {ChannelID, GuildID, UserID} from '~/BrandedTypes';
|
import type {ChannelID, GuildID, UserID} from '~/BrandedTypes';
|
||||||
|
import {ChannelTypes} from '~/Constants';
|
||||||
import type {IChannelRepository} from '~/channel/IChannelRepository';
|
import type {IChannelRepository} from '~/channel/IChannelRepository';
|
||||||
import {
|
import {
|
||||||
FeatureTemporarilyDisabledError,
|
FeatureTemporarilyDisabledError,
|
||||||
|
UnclaimedAccountRestrictedError,
|
||||||
UnknownChannelError,
|
UnknownChannelError,
|
||||||
UnknownGuildMemberError,
|
UnknownGuildMemberError,
|
||||||
UnknownUserError,
|
UnknownUserError,
|
||||||
@@ -114,6 +116,21 @@ export class VoiceService {
|
|||||||
throw new UnknownChannelError();
|
throw new UnknownChannelError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isUnclaimed = !user.passwordHash && !user.isBot;
|
||||||
|
if (isUnclaimed) {
|
||||||
|
if (channel.type === ChannelTypes.DM) {
|
||||||
|
throw new UnclaimedAccountRestrictedError('join 1:1 voice calls');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channel.type === ChannelTypes.GUILD_VOICE) {
|
||||||
|
const guild = guildId ? await this.guildRepository.findUnique(guildId) : null;
|
||||||
|
const isOwner = guild?.ownerId === userId;
|
||||||
|
if (!isOwner) {
|
||||||
|
throw new UnclaimedAccountRestrictedError('join voice channels you do not own');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mute = false;
|
let mute = false;
|
||||||
let deaf = false;
|
let deaf = false;
|
||||||
|
|
||||||
|
|||||||
@@ -96,8 +96,8 @@ linux:
|
|||||||
icon: electron-build-resources/icons-canary
|
icon: electron-build-resources/icons-canary
|
||||||
category: Network
|
category: Network
|
||||||
maintainer: Fluxer Contributors
|
maintainer: Fluxer Contributors
|
||||||
synopsis: Chat that puts you first (Canary)
|
synopsis: Fluxer Canary
|
||||||
description: Canary build of Fluxer. Chat that puts you first. Built for friends, groups, and communities.
|
description: Fluxer Canary
|
||||||
executableName: fluxercanary
|
executableName: fluxercanary
|
||||||
target:
|
target:
|
||||||
- dir
|
- dir
|
||||||
|
|||||||
@@ -96,8 +96,8 @@ linux:
|
|||||||
icon: electron-build-resources/icons-stable
|
icon: electron-build-resources/icons-stable
|
||||||
category: Network
|
category: Network
|
||||||
maintainer: Fluxer Contributors
|
maintainer: Fluxer Contributors
|
||||||
synopsis: Chat that puts you first
|
synopsis: Fluxer
|
||||||
description: Chat that puts you first. Built for friends, groups, and communities. Text, voice, and video. Open source and community-funded.
|
description: Fluxer
|
||||||
executableName: fluxer
|
executableName: fluxer
|
||||||
target:
|
target:
|
||||||
- dir
|
- dir
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Fluxer</title>
|
<title>Fluxer</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1, user-scalable=no">
|
||||||
<meta name="description" content="Chat that puts you first. Built for friends, groups, and communities. Text, voice, and video. Open source and community-funded.">
|
<meta name="description" content="Fluxer is an open-source, independent instant messaging and VoIP platform. Built for friends, groups, and communities.">
|
||||||
<link rel="preconnect" href="https://fluxerstatic.com">
|
<link rel="preconnect" href="https://fluxerstatic.com">
|
||||||
<link rel="stylesheet" href="https://fluxerstatic.com/fonts/ibm-plex.css">
|
<link rel="stylesheet" href="https://fluxerstatic.com/fonts/ibm-plex.css">
|
||||||
<link rel="manifest" href="/manifest.json">
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
|||||||
@@ -16,8 +16,7 @@
|
|||||||
"undici",
|
"undici",
|
||||||
"update-electron-app",
|
"update-electron-app",
|
||||||
"@types/ws",
|
"@types/ws",
|
||||||
"electron-builder-squirrel-windows",
|
"electron-builder-squirrel-windows"
|
||||||
"esbuild"
|
|
||||||
],
|
],
|
||||||
"ignoreBinaries": ["go"],
|
"ignoreBinaries": ["go"],
|
||||||
"ignoreExportsUsedInFile": true,
|
"ignoreExportsUsedInFile": true,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Fluxer desktop client. Chat that puts you first.",
|
"description": "Fluxer is an open-source, independent instant messaging and VoIP platform. Built for friends, groups, and communities.",
|
||||||
"homepage": "https://fluxer.app",
|
"homepage": "https://fluxer.app",
|
||||||
"author": "Fluxer Contributors <developers@fluxer.app>",
|
"author": "Fluxer Contributors <developers@fluxer.app>",
|
||||||
"sideEffects": [
|
"sideEffects": [
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ function generateManifest(cdnEndpointRaw) {
|
|||||||
name: 'Fluxer',
|
name: 'Fluxer',
|
||||||
short_name: 'Fluxer',
|
short_name: 'Fluxer',
|
||||||
description:
|
description:
|
||||||
'Chat that puts you first. Built for friends, groups, and communities. Text, voice, and video. Open source and community-funded.',
|
'Fluxer is an open-source, independent instant messaging and VoIP platform. Built for friends, groups, and communities.',
|
||||||
start_url: '/',
|
start_url: '/',
|
||||||
display: 'standalone',
|
display: 'standalone',
|
||||||
orientation: 'portrait-primary',
|
orientation: 'portrait-primary',
|
||||||
|
|||||||
@@ -508,6 +508,7 @@ export const GatewayErrorCodes = {
|
|||||||
VOICE_TOKEN_FAILED: 'VOICE_TOKEN_FAILED',
|
VOICE_TOKEN_FAILED: 'VOICE_TOKEN_FAILED',
|
||||||
VOICE_GUILD_ID_MISSING: 'VOICE_GUILD_ID_MISSING',
|
VOICE_GUILD_ID_MISSING: 'VOICE_GUILD_ID_MISSING',
|
||||||
VOICE_INVALID_GUILD_ID: 'VOICE_INVALID_GUILD_ID',
|
VOICE_INVALID_GUILD_ID: 'VOICE_INVALID_GUILD_ID',
|
||||||
|
VOICE_UNCLAIMED_ACCOUNT: 'VOICE_UNCLAIMED_ACCOUNT',
|
||||||
DM_NOT_RECIPIENT: 'DM_NOT_RECIPIENT',
|
DM_NOT_RECIPIENT: 'DM_NOT_RECIPIENT',
|
||||||
DM_INVALID_CHANNEL_TYPE: 'DM_INVALID_CHANNEL_TYPE',
|
DM_INVALID_CHANNEL_TYPE: 'DM_INVALID_CHANNEL_TYPE',
|
||||||
UNKNOWN_ERROR: 'UNKNOWN_ERROR',
|
UNKNOWN_ERROR: 'UNKNOWN_ERROR',
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export const fetch = async (userId: string, guildId?: string, force = false): Pr
|
|||||||
query: {
|
query: {
|
||||||
...(guildId ? {guild_id: guildId} : {}),
|
...(guildId ? {guild_id: guildId} : {}),
|
||||||
with_mutual_friends: true,
|
with_mutual_friends: true,
|
||||||
|
with_mutual_guilds: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const profile = response.body;
|
const profile = response.body;
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Fluxer Contributors
|
|
||||||
*
|
|
||||||
* This file is part of Fluxer.
|
|
||||||
*
|
|
||||||
* Fluxer is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* Fluxer 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 Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import i18n from '~/i18n';
|
|
||||||
import CaptchaInterceptor from '~/lib/CaptchaInterceptor';
|
|
||||||
import ChannelDisplayNameStore from '~/stores/ChannelDisplayNameStore';
|
|
||||||
import KeybindStore from '~/stores/KeybindStore';
|
|
||||||
import NewDeviceMonitoringStore from '~/stores/NewDeviceMonitoringStore';
|
|
||||||
import NotificationStore from '~/stores/NotificationStore';
|
|
||||||
import QuickSwitcherStore from '~/stores/QuickSwitcherStore';
|
|
||||||
import MediaEngineFacade from '~/stores/voice/MediaEngineFacade';
|
|
||||||
|
|
||||||
export function setupI18nStores(): void {
|
|
||||||
QuickSwitcherStore.setI18n(i18n);
|
|
||||||
ChannelDisplayNameStore.setI18n(i18n);
|
|
||||||
KeybindStore.setI18n(i18n);
|
|
||||||
NewDeviceMonitoringStore.setI18n(i18n);
|
|
||||||
NotificationStore.setI18n(i18n);
|
|
||||||
MediaEngineFacade.setI18n(i18n);
|
|
||||||
CaptchaInterceptor.setI18n(i18n);
|
|
||||||
}
|
|
||||||
@@ -84,9 +84,6 @@ function NativeDatePicker({
|
|||||||
error,
|
error,
|
||||||
}: NativeDatePickerProps) {
|
}: NativeDatePickerProps) {
|
||||||
const {t} = useLingui();
|
const {t} = useLingui();
|
||||||
const _monthPlaceholder = t`Month`;
|
|
||||||
const _dayPlaceholder = t`Day`;
|
|
||||||
const _yearPlaceholder = t`Year`;
|
|
||||||
const dateOfBirthPlaceholder = t`Date of birth`;
|
const dateOfBirthPlaceholder = t`Date of birth`;
|
||||||
|
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ import * as ChannelUtils from '~/utils/ChannelUtils';
|
|||||||
import {getMutedText, getNotificationSettingsLabel} from '~/utils/ContextMenuUtils';
|
import {getMutedText, getNotificationSettingsLabel} from '~/utils/ContextMenuUtils';
|
||||||
import {MAX_GROUP_DM_RECIPIENTS} from '~/utils/groupDmUtils';
|
import {MAX_GROUP_DM_RECIPIENTS} from '~/utils/groupDmUtils';
|
||||||
import * as InviteUtils from '~/utils/InviteUtils';
|
import * as InviteUtils from '~/utils/InviteUtils';
|
||||||
|
import * as MemberListUtils from '~/utils/MemberListUtils';
|
||||||
import {buildChannelLink} from '~/utils/messageLinkUtils';
|
import {buildChannelLink} from '~/utils/messageLinkUtils';
|
||||||
import * as NicknameUtils from '~/utils/NicknameUtils';
|
import * as NicknameUtils from '~/utils/NicknameUtils';
|
||||||
import * as RouterUtils from '~/utils/RouterUtils';
|
import * as RouterUtils from '~/utils/RouterUtils';
|
||||||
@@ -278,7 +279,18 @@ interface LazyMemberListGroupProps {
|
|||||||
const LazyMemberListGroup = observer(
|
const LazyMemberListGroup = observer(
|
||||||
({guild, group, channelId, members, onMemberLongPress}: LazyMemberListGroupProps) => {
|
({guild, group, channelId, members, onMemberLongPress}: LazyMemberListGroupProps) => {
|
||||||
const {t} = useLingui();
|
const {t} = useLingui();
|
||||||
const groupName = group.id === 'online' ? t`Online` : group.id === 'offline' ? t`Offline` : group.id;
|
const groupName = (() => {
|
||||||
|
switch (group.id) {
|
||||||
|
case 'online':
|
||||||
|
return t`Online`;
|
||||||
|
case 'offline':
|
||||||
|
return t`Offline`;
|
||||||
|
default: {
|
||||||
|
const role = guild.getRole(group.id);
|
||||||
|
return role?.name ?? group.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.memberGroupContainer}>
|
<div className={styles.memberGroupContainer}>
|
||||||
@@ -321,6 +333,7 @@ const LazyGuildMemberList = observer(
|
|||||||
guildId: guild.id,
|
guildId: guild.id,
|
||||||
channelId: channel.id,
|
channelId: channel.id,
|
||||||
enabled,
|
enabled,
|
||||||
|
allowInitialUnfocusedLoad: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const memberListState = MemberSidebarStore.getList(guild.id, channel.id);
|
const memberListState = MemberSidebarStore.getList(guild.id, channel.id);
|
||||||
@@ -364,21 +377,22 @@ const LazyGuildMemberList = observer(
|
|||||||
|
|
||||||
const groupedItems: Map<string, Array<GuildMemberRecord>> = new Map();
|
const groupedItems: Map<string, Array<GuildMemberRecord>> = new Map();
|
||||||
const groups = memberListState.groups;
|
const groups = memberListState.groups;
|
||||||
|
const seenMemberIds = new Set<string>();
|
||||||
|
|
||||||
for (const group of groups) {
|
for (const group of groups) {
|
||||||
groupedItems.set(group.id, []);
|
groupedItems.set(group.id, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [, item] of memberListState.items) {
|
let currentGroup: string | null = null;
|
||||||
if (item.type === 'member') {
|
const sortedItems = Array.from(memberListState.items.entries()).sort(([a], [b]) => a - b);
|
||||||
|
for (const [, item] of sortedItems) {
|
||||||
|
if (item.type === 'group') {
|
||||||
|
currentGroup = (item.data as {id: string}).id;
|
||||||
|
} else if (item.type === 'member' && currentGroup) {
|
||||||
const member = item.data as GuildMemberRecord;
|
const member = item.data as GuildMemberRecord;
|
||||||
for (let i = groups.length - 1; i >= 0; i--) {
|
if (!seenMemberIds.has(member.user.id)) {
|
||||||
const group = groups[i];
|
seenMemberIds.add(member.user.id);
|
||||||
const members = groupedItems.get(group.id);
|
groupedItems.get(currentGroup)?.push(member);
|
||||||
if (members) {
|
|
||||||
members.push(member);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -734,6 +748,24 @@ export const ChannelDetailsBottomSheet: React.FC<ChannelDetailsBottomSheetProps>
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isMemberTabVisible = isOpen && activeTab === 'members';
|
const isMemberTabVisible = isOpen && activeTab === 'members';
|
||||||
|
const dmMemberGroups = (() => {
|
||||||
|
if (!(isDM || isGroupDM || isPersonalNotes)) return [];
|
||||||
|
|
||||||
|
const currentUserId = AuthenticationStore.currentUserId;
|
||||||
|
let memberIds: Array<string> = [];
|
||||||
|
|
||||||
|
if (isPersonalNotes) {
|
||||||
|
memberIds = currentUser ? [currentUser.id] : [];
|
||||||
|
} else {
|
||||||
|
memberIds = [...channel.recipientIds];
|
||||||
|
if (currentUserId && !memberIds.includes(currentUserId)) {
|
||||||
|
memberIds.push(currentUserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = memberIds.map((id) => UserStore.getUser(id)).filter((u): u is UserRecord => u !== null);
|
||||||
|
return MemberListUtils.getGroupDMMemberGroups(users);
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -910,68 +942,60 @@ export const ChannelDetailsBottomSheet: React.FC<ChannelDetailsBottomSheetProps>
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.membersHeader}>
|
<div className={styles.membersHeader}>
|
||||||
<Trans>Members</Trans> —{' '}
|
<Trans>Members</Trans> — {dmMemberGroups.reduce((total, group) => total + group.count, 0)}
|
||||||
{isPersonalNotes ? 1 : isGroupDM ? channel.recipientIds.length + 1 : 2}
|
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.membersListContainer}>
|
<div className={styles.membersListContainer}>
|
||||||
{(() => {
|
{dmMemberGroups.map((group) => (
|
||||||
let memberIds: Array<string> = [];
|
<div key={group.id} className={styles.memberGroupContainer}>
|
||||||
if (isPersonalNotes) {
|
<div className={styles.memberGroupHeader}>
|
||||||
memberIds = currentUser ? [currentUser.id] : [];
|
{group.displayName} — {group.count}
|
||||||
} else if (isGroupDM) {
|
</div>
|
||||||
memberIds = [...channel.recipientIds];
|
<div className={styles.memberGroupList}>
|
||||||
if (currentUser && !memberIds.includes(currentUser.id)) {
|
{group.users.map((user, index) => {
|
||||||
memberIds.push(currentUser.id);
|
const isCurrentUser = user.id === currentUser?.id;
|
||||||
}
|
const isOwner = isGroupDM && channel.ownerId === user.id;
|
||||||
} else if (isDM) {
|
|
||||||
memberIds = [...channel.recipientIds];
|
|
||||||
if (currentUser && !memberIds.includes(currentUser.id)) {
|
|
||||||
memberIds.push(currentUser.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return memberIds.map((userId, index, arr) => {
|
const handleUserClick = () => {
|
||||||
const user = UserStore.getUser(userId);
|
UserProfileActionCreators.openUserProfile(user.id);
|
||||||
if (!user) return null;
|
};
|
||||||
|
|
||||||
const isCurrentUser = user.id === currentUser?.id;
|
return (
|
||||||
const isOwner = isGroupDM && channel.ownerId === user.id;
|
<React.Fragment key={user.id}>
|
||||||
|
<button
|
||||||
const handleUserClick = () => {
|
type="button"
|
||||||
UserProfileActionCreators.openUserProfile(user.id);
|
onClick={handleUserClick}
|
||||||
};
|
className={styles.memberItemButton}
|
||||||
|
>
|
||||||
return (
|
<StatusAwareAvatar user={user} size={40} />
|
||||||
<React.Fragment key={user.id}>
|
<div className={styles.memberItemContent}>
|
||||||
<button type="button" onClick={handleUserClick} className={styles.memberItemButton}>
|
<span className={styles.memberItemName}>
|
||||||
<StatusAwareAvatar user={user} size={40} />
|
{user.username}
|
||||||
<div className={styles.memberItemContent}>
|
{isCurrentUser && (
|
||||||
<span className={styles.memberItemName}>
|
<span className={styles.memberItemYou}>
|
||||||
{user.username}
|
{' '}
|
||||||
{isCurrentUser && (
|
<Trans>(you)</Trans>
|
||||||
<span className={styles.memberItemYou}>
|
</span>
|
||||||
{' '}
|
)}
|
||||||
<Trans>(you)</Trans>
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
{(user.bot || isOwner) && (
|
||||||
</span>
|
<div className={styles.memberItemTags}>
|
||||||
{(user.bot || isOwner) && (
|
{user.bot && <UserTag system={user.system} />}
|
||||||
<div className={styles.memberItemTags}>
|
{isOwner && (
|
||||||
{user.bot && <UserTag system={user.system} />}
|
<Tooltip text={t`Group Owner`}>
|
||||||
{isOwner && (
|
<CrownIcon className={styles.ownerCrown} weight="fill" />
|
||||||
<Tooltip text={t`Group Owner`}>
|
</Tooltip>
|
||||||
<CrownIcon className={styles.ownerCrown} weight="fill" />
|
)}
|
||||||
</Tooltip>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</button>
|
||||||
</div>
|
{index < group.users.length - 1 && <div className={styles.memberItemDivider} />}
|
||||||
</button>
|
</React.Fragment>
|
||||||
{index < arr.length - 1 && <div className={styles.memberItemDivider} />}
|
);
|
||||||
</React.Fragment>
|
})}
|
||||||
);
|
</div>
|
||||||
});
|
</div>
|
||||||
})()}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ import RuntimeConfigStore from '~/stores/RuntimeConfigStore';
|
|||||||
import SelectedChannelStore from '~/stores/SelectedChannelStore';
|
import SelectedChannelStore from '~/stores/SelectedChannelStore';
|
||||||
import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore';
|
import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore';
|
||||||
import UserProfileMobileStore from '~/stores/UserProfileMobileStore';
|
import UserProfileMobileStore from '~/stores/UserProfileMobileStore';
|
||||||
|
import UserStore from '~/stores/UserStore';
|
||||||
import * as CallUtils from '~/utils/CallUtils';
|
import * as CallUtils from '~/utils/CallUtils';
|
||||||
import {getMutedText} from '~/utils/ContextMenuUtils';
|
import {getMutedText} from '~/utils/ContextMenuUtils';
|
||||||
import * as InviteUtils from '~/utils/InviteUtils';
|
import * as InviteUtils from '~/utils/InviteUtils';
|
||||||
@@ -113,6 +114,7 @@ export const DMBottomSheet: React.FC<DMBottomSheetProps> = observer(({isOpen, on
|
|||||||
const isRecipientBot = recipient?.bot;
|
const isRecipientBot = recipient?.bot;
|
||||||
const relationship = recipient ? RelationshipStore.getRelationship(recipient.id) : null;
|
const relationship = recipient ? RelationshipStore.getRelationship(recipient.id) : null;
|
||||||
const relationshipType = relationship?.type;
|
const relationshipType = relationship?.type;
|
||||||
|
const currentUserUnclaimed = !(UserStore.currentUser?.isClaimed() ?? true);
|
||||||
|
|
||||||
const handleMarkAsRead = () => {
|
const handleMarkAsRead = () => {
|
||||||
ReadStateActionCreators.ack(channel.id, true, true);
|
ReadStateActionCreators.ack(channel.id, true, true);
|
||||||
@@ -517,7 +519,8 @@ export const DMBottomSheet: React.FC<DMBottomSheetProps> = observer(({isOpen, on
|
|||||||
});
|
});
|
||||||
} else if (
|
} else if (
|
||||||
relationshipType !== RelationshipTypes.OUTGOING_REQUEST &&
|
relationshipType !== RelationshipTypes.OUTGOING_REQUEST &&
|
||||||
relationshipType !== RelationshipTypes.BLOCKED
|
relationshipType !== RelationshipTypes.BLOCKED &&
|
||||||
|
!currentUserUnclaimed
|
||||||
) {
|
) {
|
||||||
relationshipItems.push({
|
relationshipItems.push({
|
||||||
icon: <UserPlusIcon weight="fill" className={sharedStyles.icon} />,
|
icon: <UserPlusIcon weight="fill" className={sharedStyles.icon} />,
|
||||||
|
|||||||
@@ -23,8 +23,11 @@ import {PhoneIcon, VideoCameraIcon} from '@phosphor-icons/react';
|
|||||||
import {observer} from 'mobx-react-lite';
|
import {observer} from 'mobx-react-lite';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import * as CallActionCreators from '~/actions/CallActionCreators';
|
import * as CallActionCreators from '~/actions/CallActionCreators';
|
||||||
|
import * as ToastActionCreators from '~/actions/ToastActionCreators';
|
||||||
|
import {ChannelTypes} from '~/Constants';
|
||||||
import type {ChannelRecord} from '~/records/ChannelRecord';
|
import type {ChannelRecord} from '~/records/ChannelRecord';
|
||||||
import CallStateStore from '~/stores/CallStateStore';
|
import CallStateStore from '~/stores/CallStateStore';
|
||||||
|
import UserStore from '~/stores/UserStore';
|
||||||
import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
|
import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
|
||||||
import * as CallUtils from '~/utils/CallUtils';
|
import * as CallUtils from '~/utils/CallUtils';
|
||||||
import {ChannelHeaderIcon} from './ChannelHeaderIcon';
|
import {ChannelHeaderIcon} from './ChannelHeaderIcon';
|
||||||
@@ -38,9 +41,20 @@ const VoiceCallButton = observer(({channel}: {channel: ChannelRecord}) => {
|
|||||||
const hasActiveCall = CallStateStore.hasActiveCall(channel.id);
|
const hasActiveCall = CallStateStore.hasActiveCall(channel.id);
|
||||||
const participants = call ? CallStateStore.getParticipants(channel.id) : [];
|
const participants = call ? CallStateStore.getParticipants(channel.id) : [];
|
||||||
const participantCount = participants.length;
|
const participantCount = participants.length;
|
||||||
|
const currentUser = UserStore.getCurrentUser();
|
||||||
|
const isUnclaimed = !(currentUser?.isClaimed() ?? false);
|
||||||
|
const is1to1 = channel.type === ChannelTypes.DM;
|
||||||
|
const blocked = isUnclaimed && is1to1;
|
||||||
|
|
||||||
const handleClick = React.useCallback(
|
const handleClick = React.useCallback(
|
||||||
async (event: React.MouseEvent) => {
|
async (event: React.MouseEvent) => {
|
||||||
|
if (blocked) {
|
||||||
|
ToastActionCreators.createToast({
|
||||||
|
type: 'error',
|
||||||
|
children: t`Claim your account to start or join 1:1 calls.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isInCall) {
|
if (isInCall) {
|
||||||
void CallActionCreators.leaveCall(channel.id);
|
void CallActionCreators.leaveCall(channel.id);
|
||||||
} else if (hasActiveCall) {
|
} else if (hasActiveCall) {
|
||||||
@@ -50,7 +64,7 @@ const VoiceCallButton = observer(({channel}: {channel: ChannelRecord}) => {
|
|||||||
await CallUtils.checkAndStartCall(channel.id, silent);
|
await CallUtils.checkAndStartCall(channel.id, silent);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[channel.id, isInCall, hasActiveCall],
|
[channel.id, isInCall, hasActiveCall, blocked],
|
||||||
);
|
);
|
||||||
|
|
||||||
let label: string;
|
let label: string;
|
||||||
@@ -67,7 +81,13 @@ const VoiceCallButton = observer(({channel}: {channel: ChannelRecord}) => {
|
|||||||
: t`Join Voice Call (${participantCount} participants)`;
|
: t`Join Voice Call (${participantCount} participants)`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
label = isInCall ? t`Leave Voice Call` : hasActiveCall ? t`Join Voice Call` : t`Start Voice Call`;
|
label = blocked
|
||||||
|
? t`Claim your account to call`
|
||||||
|
: isInCall
|
||||||
|
? t`Leave Voice Call`
|
||||||
|
: hasActiveCall
|
||||||
|
? t`Join Voice Call`
|
||||||
|
: t`Start Voice Call`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -76,6 +96,7 @@ const VoiceCallButton = observer(({channel}: {channel: ChannelRecord}) => {
|
|||||||
label={label}
|
label={label}
|
||||||
isSelected={isInCall}
|
isSelected={isInCall}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
disabled={blocked}
|
||||||
keybindAction="start_pm_call"
|
keybindAction="start_pm_call"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -90,9 +111,20 @@ const VideoCallButton = observer(({channel}: {channel: ChannelRecord}) => {
|
|||||||
const hasActiveCall = CallStateStore.hasActiveCall(channel.id);
|
const hasActiveCall = CallStateStore.hasActiveCall(channel.id);
|
||||||
const participants = call ? CallStateStore.getParticipants(channel.id) : [];
|
const participants = call ? CallStateStore.getParticipants(channel.id) : [];
|
||||||
const participantCount = participants.length;
|
const participantCount = participants.length;
|
||||||
|
const currentUser = UserStore.getCurrentUser();
|
||||||
|
const isUnclaimed = !(currentUser?.isClaimed() ?? false);
|
||||||
|
const is1to1 = channel.type === ChannelTypes.DM;
|
||||||
|
const blocked = isUnclaimed && is1to1;
|
||||||
|
|
||||||
const handleClick = React.useCallback(
|
const handleClick = React.useCallback(
|
||||||
async (event: React.MouseEvent) => {
|
async (event: React.MouseEvent) => {
|
||||||
|
if (blocked) {
|
||||||
|
ToastActionCreators.createToast({
|
||||||
|
type: 'error',
|
||||||
|
children: t`Claim your account to start or join 1:1 calls.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isInCall) {
|
if (isInCall) {
|
||||||
void CallActionCreators.leaveCall(channel.id);
|
void CallActionCreators.leaveCall(channel.id);
|
||||||
} else if (hasActiveCall) {
|
} else if (hasActiveCall) {
|
||||||
@@ -102,7 +134,7 @@ const VideoCallButton = observer(({channel}: {channel: ChannelRecord}) => {
|
|||||||
await CallUtils.checkAndStartCall(channel.id, silent);
|
await CallUtils.checkAndStartCall(channel.id, silent);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[channel.id, isInCall, hasActiveCall],
|
[channel.id, isInCall, hasActiveCall, blocked],
|
||||||
);
|
);
|
||||||
|
|
||||||
let label: string;
|
let label: string;
|
||||||
@@ -119,10 +151,24 @@ const VideoCallButton = observer(({channel}: {channel: ChannelRecord}) => {
|
|||||||
: t`Join Video Call (${participantCount} participants)`;
|
: t`Join Video Call (${participantCount} participants)`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
label = isInCall ? t`Leave Video Call` : hasActiveCall ? t`Join Video Call` : t`Start Video Call`;
|
label = blocked
|
||||||
|
? t`Claim your account to call`
|
||||||
|
: isInCall
|
||||||
|
? t`Leave Video Call`
|
||||||
|
: hasActiveCall
|
||||||
|
? t`Join Video Call`
|
||||||
|
: t`Start Video Call`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ChannelHeaderIcon icon={VideoCameraIcon} label={label} isSelected={isInCall} onClick={handleClick} />;
|
return (
|
||||||
|
<ChannelHeaderIcon
|
||||||
|
icon={VideoCameraIcon}
|
||||||
|
label={label}
|
||||||
|
isSelected={isInCall}
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={blocked}
|
||||||
|
/>
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export const CallButtons = {
|
export const CallButtons = {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import type {UserRecord} from '~/records/UserRecord';
|
|||||||
import AuthenticationStore from '~/stores/AuthenticationStore';
|
import AuthenticationStore from '~/stores/AuthenticationStore';
|
||||||
import MemberSidebarStore from '~/stores/MemberSidebarStore';
|
import MemberSidebarStore from '~/stores/MemberSidebarStore';
|
||||||
import UserStore from '~/stores/UserStore';
|
import UserStore from '~/stores/UserStore';
|
||||||
import type {GroupDMMemberGroup, MemberGroup} from '~/utils/MemberListUtils';
|
import type {GroupDMMemberGroup} from '~/utils/MemberListUtils';
|
||||||
import * as MemberListUtils from '~/utils/MemberListUtils';
|
import * as MemberListUtils from '~/utils/MemberListUtils';
|
||||||
import * as NicknameUtils from '~/utils/NicknameUtils';
|
import * as NicknameUtils from '~/utils/NicknameUtils';
|
||||||
import styles from './ChannelMembers.module.css';
|
import styles from './ChannelMembers.module.css';
|
||||||
@@ -65,35 +65,6 @@ const SkeletonMemberItem = ({index}: {index: number}) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const _MemberListGroup = observer(
|
|
||||||
({guild, group, channelId}: {guild: GuildRecord; group: MemberGroup; channelId: string}) => (
|
|
||||||
<div className={styles.groupContainer}>
|
|
||||||
<div className={styles.groupHeader}>
|
|
||||||
{group.displayName} — {group.count}
|
|
||||||
</div>
|
|
||||||
<div className={styles.membersList}>
|
|
||||||
{group.members.map((member: GuildMemberRecord) => {
|
|
||||||
const user = member.user;
|
|
||||||
const userId = user.id;
|
|
||||||
return (
|
|
||||||
<MemberListItem
|
|
||||||
key={userId}
|
|
||||||
user={user}
|
|
||||||
channelId={channelId}
|
|
||||||
guildId={guild.id}
|
|
||||||
isOwner={guild.isOwner(userId)}
|
|
||||||
roleColor={member.getColorString?.() ?? undefined}
|
|
||||||
displayName={NicknameUtils.getNickname(user, guild.id)}
|
|
||||||
disableBackdrop={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className={styles.groupSpacer} />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
interface GroupDMMemberListGroupProps {
|
interface GroupDMMemberListGroupProps {
|
||||||
group: GroupDMMemberGroup;
|
group: GroupDMMemberGroup;
|
||||||
channelId: string;
|
channelId: string;
|
||||||
|
|||||||
@@ -40,9 +40,7 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
|||||||
import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators';
|
import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators';
|
||||||
import {MessagePreviewContext} from '~/Constants';
|
import {MessagePreviewContext} from '~/Constants';
|
||||||
import {Message, type MessageBehaviorOverrides} from '~/components/channel/Message';
|
import {Message, type MessageBehaviorOverrides} from '~/components/channel/Message';
|
||||||
import {GroupDMAvatar} from '~/components/common/GroupDMAvatar';
|
|
||||||
import {MessageContextPrefix} from '~/components/shared/MessageContextPrefix/MessageContextPrefix';
|
import {MessageContextPrefix} from '~/components/shared/MessageContextPrefix/MessageContextPrefix';
|
||||||
import {Avatar} from '~/components/uikit/Avatar';
|
|
||||||
import {Button} from '~/components/uikit/Button/Button';
|
import {Button} from '~/components/uikit/Button/Button';
|
||||||
import {ContextMenuCloseProvider} from '~/components/uikit/ContextMenu/ContextMenu';
|
import {ContextMenuCloseProvider} from '~/components/uikit/ContextMenu/ContextMenu';
|
||||||
import {MenuGroup} from '~/components/uikit/ContextMenu/MenuGroup';
|
import {MenuGroup} from '~/components/uikit/ContextMenu/MenuGroup';
|
||||||
@@ -58,9 +56,7 @@ import ChannelSearchStore, {getChannelSearchContextId} from '~/stores/ChannelSea
|
|||||||
import ChannelStore from '~/stores/ChannelStore';
|
import ChannelStore from '~/stores/ChannelStore';
|
||||||
import GuildNSFWAgreeStore from '~/stores/GuildNSFWAgreeStore';
|
import GuildNSFWAgreeStore from '~/stores/GuildNSFWAgreeStore';
|
||||||
import GuildStore from '~/stores/GuildStore';
|
import GuildStore from '~/stores/GuildStore';
|
||||||
import UserStore from '~/stores/UserStore';
|
|
||||||
import {applyChannelSearchHighlight, clearChannelSearchHighlight} from '~/utils/ChannelSearchHighlight';
|
import {applyChannelSearchHighlight, clearChannelSearchHighlight} from '~/utils/ChannelSearchHighlight';
|
||||||
import * as ChannelUtils from '~/utils/ChannelUtils';
|
|
||||||
import {goToMessage} from '~/utils/MessageNavigator';
|
import {goToMessage} from '~/utils/MessageNavigator';
|
||||||
import * as RouterUtils from '~/utils/RouterUtils';
|
import * as RouterUtils from '~/utils/RouterUtils';
|
||||||
import {tokenizeSearchQuery} from '~/utils/SearchQueryTokenizer';
|
import {tokenizeSearchQuery} from '~/utils/SearchQueryTokenizer';
|
||||||
@@ -78,14 +74,6 @@ import type {SearchMachineState} from './SearchResultsUtils';
|
|||||||
import {areSegmentsEqual} from './SearchResultsUtils';
|
import {areSegmentsEqual} from './SearchResultsUtils';
|
||||||
import {DEFAULT_SCOPE_VALUE, getScopeOptionsForChannel} from './searchScopeOptions';
|
import {DEFAULT_SCOPE_VALUE, getScopeOptionsForChannel} from './searchScopeOptions';
|
||||||
|
|
||||||
const getChannelDisplayName = (channel: ChannelRecord): string => {
|
|
||||||
if (channel.isPrivate()) {
|
|
||||||
return ChannelUtils.getDMDisplayName(channel);
|
|
||||||
}
|
|
||||||
|
|
||||||
return channel.name?.trim() || ChannelUtils.getName(channel);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getChannelGuild = (channel: ChannelRecord): GuildRecord | null => {
|
const getChannelGuild = (channel: ChannelRecord): GuildRecord | null => {
|
||||||
if (!channel.guildId) {
|
if (!channel.guildId) {
|
||||||
return null;
|
return null;
|
||||||
@@ -101,37 +89,6 @@ const getChannelPath = (channel: ChannelRecord): string => {
|
|||||||
return Routes.dmChannel(channel.id);
|
return Routes.dmChannel(channel.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const _renderChannelIcon = (channel: ChannelRecord): React.ReactNode => {
|
|
||||||
if (channel.isPersonalNotes()) {
|
|
||||||
return ChannelUtils.getIcon(channel, {className: styles.channelIcon});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (channel.isDM()) {
|
|
||||||
const recipientId = channel.recipientIds[0];
|
|
||||||
const recipient = recipientId ? UserStore.getUser(recipientId) : null;
|
|
||||||
|
|
||||||
if (recipient) {
|
|
||||||
return (
|
|
||||||
<div className={styles.channelIconAvatar}>
|
|
||||||
<Avatar user={recipient} size={20} status={null} className={styles.channelIconAvatarImage} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ChannelUtils.getIcon(channel, {className: styles.channelIcon});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (channel.isGroupDM()) {
|
|
||||||
return (
|
|
||||||
<div className={styles.channelIconAvatar}>
|
|
||||||
<GroupDMAvatar channel={channel} size={20} disableStatusIndicator />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ChannelUtils.getIcon(channel, {className: styles.channelIcon});
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ChannelSearchResultsProps {
|
interface ChannelSearchResultsProps {
|
||||||
channel: ChannelRecord;
|
channel: ChannelRecord;
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
@@ -753,7 +710,6 @@ export const ChannelSearchResults = observer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const channelGuild = getChannelGuild(messageChannel);
|
const channelGuild = getChannelGuild(messageChannel);
|
||||||
const _channelDisplayName = getChannelDisplayName(messageChannel);
|
|
||||||
const showGuildMeta = shouldShowGuildMetaForScope(
|
const showGuildMeta = shouldShowGuildMetaForScope(
|
||||||
channelGuild,
|
channelGuild,
|
||||||
(activeScope ?? DEFAULT_SCOPE_VALUE) as MessageSearchScope,
|
(activeScope ?? DEFAULT_SCOPE_VALUE) as MessageSearchScope,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
} from '~/components/embeds/EmbedCard/EmbedCard';
|
} from '~/components/embeds/EmbedCard/EmbedCard';
|
||||||
import cardStyles from '~/components/embeds/EmbedCard/EmbedCard.module.css';
|
import cardStyles from '~/components/embeds/EmbedCard/EmbedCard.module.css';
|
||||||
import {useEmbedSkeletonOverride} from '~/components/embeds/EmbedCard/useEmbedSkeletonOverride';
|
import {useEmbedSkeletonOverride} from '~/components/embeds/EmbedCard/useEmbedSkeletonOverride';
|
||||||
|
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
||||||
import {Button} from '~/components/uikit/Button/Button';
|
import {Button} from '~/components/uikit/Button/Button';
|
||||||
import i18n from '~/i18n';
|
import i18n from '~/i18n';
|
||||||
import {ComponentDispatch} from '~/lib/ComponentDispatch';
|
import {ComponentDispatch} from '~/lib/ComponentDispatch';
|
||||||
@@ -48,6 +49,7 @@ export const GiftEmbed = observer(function GiftEmbed({code}: GiftEmbedProps) {
|
|||||||
const giftState = GiftStore.gifts.get(code) ?? null;
|
const giftState = GiftStore.gifts.get(code) ?? null;
|
||||||
const gift = giftState?.data;
|
const gift = giftState?.data;
|
||||||
const creator = UserStore.getUser(gift?.created_by?.id ?? '');
|
const creator = UserStore.getUser(gift?.created_by?.id ?? '');
|
||||||
|
const isUnclaimed = !(UserStore.currentUser?.isClaimed() ?? false);
|
||||||
const shouldForceSkeleton = useEmbedSkeletonOverride();
|
const shouldForceSkeleton = useEmbedSkeletonOverride();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -76,6 +78,10 @@ export const GiftEmbed = observer(function GiftEmbed({code}: GiftEmbedProps) {
|
|||||||
const durationText = getGiftDurationText(i18n, gift);
|
const durationText = getGiftDurationText(i18n, gift);
|
||||||
|
|
||||||
const handleRedeem = async () => {
|
const handleRedeem = async () => {
|
||||||
|
if (isUnclaimed) {
|
||||||
|
openClaimAccountModal({force: true});
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await GiftActionCreators.redeem(i18n, code);
|
await GiftActionCreators.redeem(i18n, code);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -87,17 +93,22 @@ export const GiftEmbed = observer(function GiftEmbed({code}: GiftEmbedProps) {
|
|||||||
<span className={styles.subRow}>{t`From ${creator.username}#${creator.discriminator}`}</span>
|
<span className={styles.subRow}>{t`From ${creator.username}#${creator.discriminator}`}</span>
|
||||||
) : undefined;
|
) : undefined;
|
||||||
|
|
||||||
const helpText = gift.redeemed ? t`Already redeemed` : t`Click to claim your gift!`;
|
const helpText = gift.redeemed
|
||||||
|
? t`Already redeemed`
|
||||||
|
: isUnclaimed
|
||||||
|
? t`Claim your account to redeem this gift.`
|
||||||
|
: t`Click to claim your gift!`;
|
||||||
|
|
||||||
const footer = gift.redeemed ? (
|
const footer =
|
||||||
<Button variant="primary" matchSkeletonHeight disabled>
|
gift.redeemed && !isUnclaimed ? (
|
||||||
{t`Gift Claimed`}
|
<Button variant="primary" matchSkeletonHeight disabled>
|
||||||
</Button>
|
{t`Gift Claimed`}
|
||||||
) : (
|
</Button>
|
||||||
<Button variant="primary" matchSkeletonHeight onClick={handleRedeem}>
|
) : (
|
||||||
{t`Claim Gift`}
|
<Button variant="primary" matchSkeletonHeight onClick={handleRedeem} disabled={gift.redeemed || isUnclaimed}>
|
||||||
</Button>
|
{gift.redeemed ? t`Gift Claimed` : isUnclaimed ? t`Claim Account to Redeem` : t`Claim Gift`}
|
||||||
);
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmbedCard
|
<EmbedCard
|
||||||
|
|||||||
@@ -108,7 +108,6 @@ const MessageReactionItem = observer(
|
|||||||
|
|
||||||
const emojiName = getEmojiName(reaction.emoji);
|
const emojiName = getEmojiName(reaction.emoji);
|
||||||
const emojiUrl = useEmojiURL({emoji: reaction.emoji, isHovering});
|
const emojiUrl = useEmojiURL({emoji: reaction.emoji, isHovering});
|
||||||
const _emojiIdentifier = reaction.emoji.id ?? reaction.emoji.name;
|
|
||||||
const isUnicodeEmoji = reaction.emoji.id == null;
|
const isUnicodeEmoji = reaction.emoji.id == null;
|
||||||
|
|
||||||
const variants = {
|
const variants = {
|
||||||
|
|||||||
@@ -280,9 +280,7 @@ export const Messages = observer(function Messages({channel}: {channel: ChannelR
|
|||||||
const data = payload as {channelId?: string; heightDelta?: number} | undefined;
|
const data = payload as {channelId?: string; heightDelta?: number} | undefined;
|
||||||
if (data?.channelId && data.channelId !== channel.id) return;
|
if (data?.channelId && data.channelId !== channel.id) return;
|
||||||
|
|
||||||
if (scrollManager.isPinned()) {
|
scrollManager.handleScroll();
|
||||||
scrollManager.handleScroll();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFocusBottommostMessage = (payload?: unknown) => {
|
const onFocusBottommostMessage = (payload?: unknown) => {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {observer} from 'mobx-react-lite';
|
|||||||
import {useEffect, useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||||
import {modal} from '~/actions/ModalActionCreators';
|
import {modal} from '~/actions/ModalActionCreators';
|
||||||
import {ClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
||||||
import {PhoneAddModal} from '~/components/modals/PhoneAddModal';
|
import {PhoneAddModal} from '~/components/modals/PhoneAddModal';
|
||||||
import {UserSettingsModal} from '~/components/modals/UserSettingsModal';
|
import {UserSettingsModal} from '~/components/modals/UserSettingsModal';
|
||||||
import {Button} from '~/components/uikit/Button/Button';
|
import {Button} from '~/components/uikit/Button/Button';
|
||||||
@@ -103,7 +103,7 @@ export const UnclaimedAccountBarrier = observer(({onAction}: BarrierProps) => {
|
|||||||
small={true}
|
small={true}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onAction?.();
|
onAction?.();
|
||||||
ModalActionCreators.push(modal(() => <ClaimAccountModal />));
|
openClaimAccountModal({force: true});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trans>Claim Account</Trans>
|
<Trans>Claim Account</Trans>
|
||||||
@@ -234,7 +234,7 @@ export const UnclaimedDMBarrier = observer(({onAction}: BarrierProps) => {
|
|||||||
small={true}
|
small={true}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onAction?.();
|
onAction?.();
|
||||||
ModalActionCreators.push(modal(() => <ClaimAccountModal />));
|
openClaimAccountModal({force: true});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trans>Claim Account</Trans>
|
<Trans>Claim Account</Trans>
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ export const DMChannelView = observer(({channelId}: DMChannelViewProps) => {
|
|||||||
const title = isDM && displayName ? `@${displayName}` : displayName;
|
const title = isDM && displayName ? `@${displayName}` : displayName;
|
||||||
useFluxerDocumentTitle(title);
|
useFluxerDocumentTitle(title);
|
||||||
const isGroupDM = channel?.type === ChannelTypes.GROUP_DM;
|
const isGroupDM = channel?.type === ChannelTypes.GROUP_DM;
|
||||||
|
const isPersonalNotes = channel?.type === ChannelTypes.DM_PERSONAL_NOTES;
|
||||||
const callHeaderState = useCallHeaderState(channel);
|
const callHeaderState = useCallHeaderState(channel);
|
||||||
const call = callHeaderState.call;
|
const call = callHeaderState.call;
|
||||||
const showCompactVoiceView = callHeaderState.controlsVariant === 'inCall';
|
const showCompactVoiceView = callHeaderState.controlsVariant === 'inCall';
|
||||||
@@ -411,7 +412,7 @@ export const DMChannelView = observer(({channelId}: DMChannelViewProps) => {
|
|||||||
textarea={
|
textarea={
|
||||||
isDM && isRecipientBlocked && recipient ? (
|
isDM && isRecipientBlocked && recipient ? (
|
||||||
<BlockedUserBarrier userId={recipient.id} username={recipient.username} />
|
<BlockedUserBarrier userId={recipient.id} username={recipient.username} />
|
||||||
) : isCurrentUserUnclaimed ? (
|
) : isCurrentUserUnclaimed && isDM && !isPersonalNotes && !isGroupDM ? (
|
||||||
<UnclaimedDMBarrier />
|
<UnclaimedDMBarrier />
|
||||||
) : (
|
) : (
|
||||||
<ChannelTextarea channel={channel} />
|
<ChannelTextarea channel={channel} />
|
||||||
|
|||||||
@@ -17,15 +17,19 @@
|
|||||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {useLingui} from '@lingui/react/macro';
|
import {Trans, useLingui} from '@lingui/react/macro';
|
||||||
|
import {WarningCircleIcon} from '@phosphor-icons/react';
|
||||||
import {clsx} from 'clsx';
|
import {clsx} from 'clsx';
|
||||||
import {observer} from 'mobx-react-lite';
|
import {observer} from 'mobx-react-lite';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import * as RelationshipActionCreators from '~/actions/RelationshipActionCreators';
|
import * as RelationshipActionCreators from '~/actions/RelationshipActionCreators';
|
||||||
import {APIErrorCodes} from '~/Constants';
|
import {APIErrorCodes} from '~/Constants';
|
||||||
import {Input} from '~/components/form/Input';
|
import {Input} from '~/components/form/Input';
|
||||||
|
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
||||||
|
import {StatusSlate} from '~/components/modals/shared/StatusSlate';
|
||||||
import {Button} from '~/components/uikit/Button/Button';
|
import {Button} from '~/components/uikit/Button/Button';
|
||||||
import MobileLayoutStore from '~/stores/MobileLayoutStore';
|
import MobileLayoutStore from '~/stores/MobileLayoutStore';
|
||||||
|
import UserStore from '~/stores/UserStore';
|
||||||
import {getApiErrorCode} from '~/utils/ApiErrorUtils';
|
import {getApiErrorCode} from '~/utils/ApiErrorUtils';
|
||||||
import styles from './AddFriendForm.module.css';
|
import styles from './AddFriendForm.module.css';
|
||||||
|
|
||||||
@@ -35,11 +39,30 @@ interface AddFriendFormProps {
|
|||||||
|
|
||||||
export const AddFriendForm: React.FC<AddFriendFormProps> = observer(({onSuccess}) => {
|
export const AddFriendForm: React.FC<AddFriendFormProps> = observer(({onSuccess}) => {
|
||||||
const {t} = useLingui();
|
const {t} = useLingui();
|
||||||
|
|
||||||
const [input, setInput] = React.useState('');
|
const [input, setInput] = React.useState('');
|
||||||
const [isLoading, setIsLoading] = React.useState(false);
|
const [isLoading, setIsLoading] = React.useState(false);
|
||||||
const [resultStatus, setResultStatus] = React.useState<'success' | 'error' | null>(null);
|
const [resultStatus, setResultStatus] = React.useState<'success' | 'error' | null>(null);
|
||||||
const [errorCode, setErrorCode] = React.useState<string | null>(null);
|
const [errorCode, setErrorCode] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const isClaimed = UserStore.currentUser?.isClaimed() ?? true;
|
||||||
|
if (!isClaimed) {
|
||||||
|
return (
|
||||||
|
<StatusSlate
|
||||||
|
Icon={WarningCircleIcon}
|
||||||
|
title={<Trans>Claim your account</Trans>}
|
||||||
|
description={<Trans>Claim your account to send friend requests.</Trans>}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
text: <Trans>Claim Account</Trans>,
|
||||||
|
onClick: () => openClaimAccountModal({force: true}),
|
||||||
|
variant: 'primary',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const parseInput = (input: string): [string, string] => {
|
const parseInput = (input: string): [string, string] => {
|
||||||
const parts = input.split('#');
|
const parts = input.split('#');
|
||||||
if (parts.length > 1) {
|
if (parts.length > 1) {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {AvatarStack} from '~/components/uikit/avatars/AvatarStack';
|
|||||||
import {Button} from '~/components/uikit/Button/Button';
|
import {Button} from '~/components/uikit/Button/Button';
|
||||||
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
|
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
|
||||||
import {StatusAwareAvatar} from '~/components/uikit/StatusAwareAvatar';
|
import {StatusAwareAvatar} from '~/components/uikit/StatusAwareAvatar';
|
||||||
|
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||||
import type {ProfileRecord} from '~/records/ProfileRecord';
|
import type {ProfileRecord} from '~/records/ProfileRecord';
|
||||||
import GuildMemberStore from '~/stores/GuildMemberStore';
|
import GuildMemberStore from '~/stores/GuildMemberStore';
|
||||||
import MobileLayoutStore from '~/stores/MobileLayoutStore';
|
import MobileLayoutStore from '~/stores/MobileLayoutStore';
|
||||||
@@ -92,6 +93,7 @@ export const DMWelcomeSection: React.FC<DMWelcomeSectionProps> = observer(functi
|
|||||||
};
|
};
|
||||||
|
|
||||||
const hasMutualGuilds = mutualGuilds.length > 0;
|
const hasMutualGuilds = mutualGuilds.length > 0;
|
||||||
|
const currentUserUnclaimed = !(UserStore.currentUser?.isClaimed() ?? true);
|
||||||
const shouldShowActionButton =
|
const shouldShowActionButton =
|
||||||
!user.bot &&
|
!user.bot &&
|
||||||
(relationshipType === undefined ||
|
(relationshipType === undefined ||
|
||||||
@@ -103,12 +105,22 @@ export const DMWelcomeSection: React.FC<DMWelcomeSectionProps> = observer(functi
|
|||||||
const renderActionButton = () => {
|
const renderActionButton = () => {
|
||||||
if (user.bot) return null;
|
if (user.bot) return null;
|
||||||
switch (relationshipType) {
|
switch (relationshipType) {
|
||||||
case undefined:
|
case undefined: {
|
||||||
return (
|
const tooltipText = t`Claim your account to send friend requests.`;
|
||||||
<Button small={true} onClick={handleSendFriendRequest}>
|
const button = (
|
||||||
|
<Button small={true} onClick={handleSendFriendRequest} disabled={currentUserUnclaimed}>
|
||||||
<Trans>Send Friend Request</Trans>
|
<Trans>Send Friend Request</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
if (currentUserUnclaimed) {
|
||||||
|
return (
|
||||||
|
<Tooltip text={tooltipText} maxWidth="xl">
|
||||||
|
<div>{button}</div>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return button;
|
||||||
|
}
|
||||||
case RelationshipTypes.INCOMING_REQUEST:
|
case RelationshipTypes.INCOMING_REQUEST:
|
||||||
return (
|
return (
|
||||||
<div className={styles.actionButtonsContainer}>
|
<div className={styles.actionButtonsContainer}>
|
||||||
|
|||||||
@@ -457,8 +457,8 @@ export const EmbedGifv: FC<
|
|||||||
const {width, aspectRatio} = style;
|
const {width, aspectRatio} = style;
|
||||||
const containerStyle = {
|
const containerStyle = {
|
||||||
'--embed-width': `${width}px`,
|
'--embed-width': `${width}px`,
|
||||||
maxWidth: `${width}px`,
|
maxWidth: '100%',
|
||||||
width: '100%',
|
width,
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
} as React.CSSProperties;
|
} as React.CSSProperties;
|
||||||
|
|
||||||
@@ -488,7 +488,7 @@ export const EmbedGifv: FC<
|
|||||||
type="gifv"
|
type="gifv"
|
||||||
handlePress={openImagePreview}
|
handlePress={openImagePreview}
|
||||||
>
|
>
|
||||||
<div className={styles.videoWrapper}>
|
<div className={styles.videoWrapper} style={aspectRatio ? {aspectRatio} : undefined}>
|
||||||
{(!loaded || error) && thumbHashURL && (
|
{(!loaded || error) && thumbHashURL && (
|
||||||
<img src={thumbHashURL} className={styles.thumbHashPlaceholder} alt={t`Loading placeholder`} />
|
<img src={thumbHashURL} className={styles.thumbHashPlaceholder} alt={t`Loading placeholder`} />
|
||||||
)}
|
)}
|
||||||
@@ -551,6 +551,7 @@ export const EmbedGif: FC<GifvEmbedProps & {proxyURL: string; includeButton?: bo
|
|||||||
const {shouldBlur, gateReason} = useNSFWMedia(nsfw, channelId);
|
const {shouldBlur, gateReason} = useNSFWMedia(nsfw, channelId);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const imgRef = useRef<HTMLImageElement>(null);
|
const imgRef = useRef<HTMLImageElement>(null);
|
||||||
|
const isHoveredRef = useRef(false);
|
||||||
|
|
||||||
const defaultName = deriveDefaultNameFromMessage({
|
const defaultName = deriveDefaultNameFromMessage({
|
||||||
message,
|
message,
|
||||||
@@ -622,16 +623,21 @@ export const EmbedGif: FC<GifvEmbedProps & {proxyURL: string; includeButton?: bo
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (gifAutoPlay) return;
|
if (gifAutoPlay) return;
|
||||||
|
|
||||||
const img = imgRef.current;
|
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!img || !container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const handleMouseEnter = () => {
|
const handleMouseEnter = () => {
|
||||||
if (FocusManager.isFocused() && img) {
|
isHoveredRef.current = true;
|
||||||
img.src = optimizedAnimatedURL;
|
if (FocusManager.isFocused()) {
|
||||||
|
const img = imgRef.current;
|
||||||
|
if (img) {
|
||||||
|
img.src = optimizedAnimatedURL;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleMouseLeave = () => {
|
const handleMouseLeave = () => {
|
||||||
|
isHoveredRef.current = false;
|
||||||
|
const img = imgRef.current;
|
||||||
if (img) {
|
if (img) {
|
||||||
img.src = optimizedStaticURL;
|
img.src = optimizedStaticURL;
|
||||||
}
|
}
|
||||||
@@ -646,6 +652,24 @@ export const EmbedGif: FC<GifvEmbedProps & {proxyURL: string; includeButton?: bo
|
|||||||
};
|
};
|
||||||
}, [gifAutoPlay, optimizedAnimatedURL, optimizedStaticURL]);
|
}, [gifAutoPlay, optimizedAnimatedURL, optimizedStaticURL]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (gifAutoPlay) return;
|
||||||
|
|
||||||
|
const unsubscribe = FocusManager.subscribe((focused) => {
|
||||||
|
const img = imgRef.current;
|
||||||
|
if (!img) return;
|
||||||
|
if (!focused) {
|
||||||
|
img.src = optimizedStaticURL;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isHoveredRef.current && focused) {
|
||||||
|
img.src = optimizedAnimatedURL;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
}, [gifAutoPlay, optimizedAnimatedURL, optimizedStaticURL]);
|
||||||
|
|
||||||
if (shouldBlur) {
|
if (shouldBlur) {
|
||||||
const {style} = mediaCalculator.calculate({width: naturalWidth, height: naturalHeight}, {forceScale: true});
|
const {style} = mediaCalculator.calculate({width: naturalWidth, height: naturalHeight}, {forceScale: true});
|
||||||
const {width: _width, height: _height, ...styleWithoutDimensions} = style;
|
const {width: _width, height: _height, ...styleWithoutDimensions} = style;
|
||||||
@@ -679,8 +703,8 @@ export const EmbedGif: FC<GifvEmbedProps & {proxyURL: string; includeButton?: bo
|
|||||||
const {width, aspectRatio} = style;
|
const {width, aspectRatio} = style;
|
||||||
const containerStyle = {
|
const containerStyle = {
|
||||||
'--embed-width': `${width}px`,
|
'--embed-width': `${width}px`,
|
||||||
maxWidth: `${width}px`,
|
maxWidth: '100%',
|
||||||
width: '100%',
|
width,
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
} as React.CSSProperties;
|
} as React.CSSProperties;
|
||||||
|
|
||||||
@@ -716,7 +740,7 @@ export const EmbedGif: FC<GifvEmbedProps & {proxyURL: string; includeButton?: bo
|
|||||||
contentHash={contentHash}
|
contentHash={contentHash}
|
||||||
message={message}
|
message={message}
|
||||||
>
|
>
|
||||||
<div className={styles.videoWrapper}>
|
<div className={styles.videoWrapper} style={aspectRatio ? {aspectRatio} : undefined}>
|
||||||
{(!loaded || error) && thumbHashURL && (
|
{(!loaded || error) && thumbHashURL && (
|
||||||
<img src={thumbHashURL} className={styles.thumbHashPlaceholder} alt={t`Loading placeholder`} />
|
<img src={thumbHashURL} className={styles.thumbHashPlaceholder} alt={t`Loading placeholder`} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -251,6 +251,11 @@ export const EmbedImage: FC<EmbedImageProps> = observer(
|
|||||||
aspectRatio: true,
|
aspectRatio: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
const resolvedContainerStyle: React.CSSProperties = {
|
||||||
|
...containerStyle,
|
||||||
|
width: dimensions.width,
|
||||||
|
maxWidth: '100%',
|
||||||
|
};
|
||||||
|
|
||||||
const shouldRenderPlaceholder = error || !loaded;
|
const shouldRenderPlaceholder = error || !loaded;
|
||||||
|
|
||||||
@@ -295,7 +300,7 @@ export const EmbedImage: FC<EmbedImageProps> = observer(
|
|||||||
<div className={styles.blurContainer}>
|
<div className={styles.blurContainer}>
|
||||||
<div className={clsx(styles.rowContainer, isInline && styles.justifyEnd)}>
|
<div className={clsx(styles.rowContainer, isInline && styles.justifyEnd)}>
|
||||||
<div className={styles.innerContainer}>
|
<div className={styles.innerContainer}>
|
||||||
<div className={styles.imageWrapper} style={containerStyle}>
|
<div className={styles.imageWrapper} style={resolvedContainerStyle}>
|
||||||
<div className={styles.imageContainer}>
|
<div className={styles.imageContainer}>
|
||||||
{thumbHashURL && (
|
{thumbHashURL && (
|
||||||
<div className={styles.thumbHashContainer}>
|
<div className={styles.thumbHashContainer}>
|
||||||
@@ -328,7 +333,7 @@ export const EmbedImage: FC<EmbedImageProps> = observer(
|
|||||||
<div className={clsx(styles.rowContainer, isInline && styles.justifyEnd)}>
|
<div className={clsx(styles.rowContainer, isInline && styles.justifyEnd)}>
|
||||||
<MediaContainer
|
<MediaContainer
|
||||||
className={clsx(styles.mediaContainer, styles.cursorPointer)}
|
className={clsx(styles.mediaContainer, styles.cursorPointer)}
|
||||||
style={containerStyle}
|
style={resolvedContainerStyle}
|
||||||
showFavoriteButton={showFavoriteButton}
|
showFavoriteButton={showFavoriteButton}
|
||||||
isFavorited={isFavorited}
|
isFavorited={isFavorited}
|
||||||
onFavoriteClick={handleFavoriteClick}
|
onFavoriteClick={handleFavoriteClick}
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
|
|||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
width: dimensions.width,
|
width: dimensions.width,
|
||||||
maxWidth: dimensions.width,
|
maxWidth: '100%',
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,6 @@ export const PickerSearchInput = React.forwardRef<HTMLInputElement, PickerSearch
|
|||||||
{value, onChange, placeholder, inputRef, onKeyDown, maxLength = 100, showBackButton = false, onBackButtonClick},
|
{value, onChange, placeholder, inputRef, onKeyDown, maxLength = 100, showBackButton = false, onBackButtonClick},
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
) => {
|
) => {
|
||||||
const _inputElementRef = React.useRef<HTMLInputElement | null>(null);
|
|
||||||
const {t} = useLingui();
|
const {t} = useLingui();
|
||||||
const inputElementRef = React.useRef<HTMLInputElement | null>(null);
|
const inputElementRef = React.useRef<HTMLInputElement | null>(null);
|
||||||
const {canFocus, safeFocusTextarea} = useInputFocusManagement(inputElementRef);
|
const {canFocus, safeFocusTextarea} = useInputFocusManagement(inputElementRef);
|
||||||
|
|||||||
@@ -18,14 +18,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {FloatingPortal} from '@floating-ui/react';
|
import {FloatingPortal} from '@floating-ui/react';
|
||||||
import {Trans, useLingui} from '@lingui/react/macro';
|
import {Trans} from '@lingui/react/macro';
|
||||||
import {PencilIcon, SealCheckIcon, SmileyIcon} from '@phosphor-icons/react';
|
import {PencilIcon, SmileyIcon} from '@phosphor-icons/react';
|
||||||
import {clsx} from 'clsx';
|
import {clsx} from 'clsx';
|
||||||
import {AnimatePresence, motion} from 'framer-motion';
|
import {AnimatePresence, motion} from 'framer-motion';
|
||||||
import {observer} from 'mobx-react-lite';
|
import {observer} from 'mobx-react-lite';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {GuildFeatures} from '~/Constants';
|
import {EmojiAttributionSubtext, getEmojiAttribution} from '~/components/emojis/EmojiAttributionSubtext';
|
||||||
import {GuildIcon} from '~/components/popouts/GuildIcon';
|
|
||||||
import {EmojiTooltipContent} from '~/components/uikit/EmojiTooltipContent/EmojiTooltipContent';
|
import {EmojiTooltipContent} from '~/components/uikit/EmojiTooltipContent/EmojiTooltipContent';
|
||||||
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
|
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
|
||||||
import {useTooltipPortalRoot} from '~/components/uikit/Tooltip';
|
import {useTooltipPortalRoot} from '~/components/uikit/Tooltip';
|
||||||
@@ -35,7 +34,6 @@ import {useReactionTooltip} from '~/hooks/useReactionTooltip';
|
|||||||
import {type CustomStatus, getCustomStatusText, normalizeCustomStatus} from '~/lib/customStatus';
|
import {type CustomStatus, getCustomStatusText, normalizeCustomStatus} from '~/lib/customStatus';
|
||||||
import UnicodeEmojis from '~/lib/UnicodeEmojis';
|
import UnicodeEmojis from '~/lib/UnicodeEmojis';
|
||||||
import EmojiStore from '~/stores/EmojiStore';
|
import EmojiStore from '~/stores/EmojiStore';
|
||||||
import GuildListStore from '~/stores/GuildListStore';
|
|
||||||
import GuildStore from '~/stores/GuildStore';
|
import GuildStore from '~/stores/GuildStore';
|
||||||
import MobileLayoutStore from '~/stores/MobileLayoutStore';
|
import MobileLayoutStore from '~/stores/MobileLayoutStore';
|
||||||
import PresenceStore from '~/stores/PresenceStore';
|
import PresenceStore from '~/stores/PresenceStore';
|
||||||
@@ -128,62 +126,6 @@ const getTooltipEmojiUrl = (status: CustomStatus): string | null => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const StatusEmojiTooltipSubtext = observer(({status}: {status: CustomStatus}) => {
|
|
||||||
const {t} = useLingui();
|
|
||||||
const isCustomEmoji = Boolean(status.emojiId);
|
|
||||||
|
|
||||||
if (!isCustomEmoji) {
|
|
||||||
return (
|
|
||||||
<span>
|
|
||||||
<Trans>This is a default emoji on Fluxer.</Trans>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const emoji = status.emojiId ? EmojiStore.getEmojiById(status.emojiId) : null;
|
|
||||||
const guildId = emoji?.guildId;
|
|
||||||
const isMember = guildId ? GuildListStore.guilds.some((guild) => guild.id === guildId) : false;
|
|
||||||
|
|
||||||
if (!isMember) {
|
|
||||||
return (
|
|
||||||
<span>
|
|
||||||
<Trans>This is a custom emoji from a community. Ask the author for an invite to use this emoji.</Trans>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const guild = guildId ? GuildStore.getGuild(guildId) : null;
|
|
||||||
|
|
||||||
if (!guild) {
|
|
||||||
return (
|
|
||||||
<span>
|
|
||||||
<Trans>This is a custom emoji from a community.</Trans>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isVerified = guild.features.has(GuildFeatures.VERIFIED);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.emojiTooltipSubtext}>
|
|
||||||
<span>
|
|
||||||
<Trans>This is a custom emoji from</Trans>
|
|
||||||
</span>
|
|
||||||
<div className={styles.emojiTooltipGuildRow}>
|
|
||||||
<div className={styles.emojiTooltipGuildIcon}>
|
|
||||||
<GuildIcon id={guild.id} name={guild.name} icon={guild.icon} sizePx={20} />
|
|
||||||
</div>
|
|
||||||
<span className={styles.emojiTooltipGuildName}>{guild.name}</span>
|
|
||||||
{isVerified && (
|
|
||||||
<Tooltip text={t`Verified Community`} position="top">
|
|
||||||
<SealCheckIcon className={styles.emojiTooltipVerifiedIcon} />
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
interface StatusEmojiWithTooltipProps {
|
interface StatusEmojiWithTooltipProps {
|
||||||
status: CustomStatus;
|
status: CustomStatus;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -195,6 +137,13 @@ const StatusEmojiWithTooltip = observer(
|
|||||||
({status, children, onClick, isButton = false}: StatusEmojiWithTooltipProps) => {
|
({status, children, onClick, isButton = false}: StatusEmojiWithTooltipProps) => {
|
||||||
const tooltipPortalRoot = useTooltipPortalRoot();
|
const tooltipPortalRoot = useTooltipPortalRoot();
|
||||||
const {targetRef, tooltipRef, state, updatePosition, handlers, tooltipHandlers} = useReactionTooltip(500);
|
const {targetRef, tooltipRef, state, updatePosition, handlers, tooltipHandlers} = useReactionTooltip(500);
|
||||||
|
const emoji = status.emojiId ? EmojiStore.getEmojiById(status.emojiId) : null;
|
||||||
|
const attribution = getEmojiAttribution({
|
||||||
|
emojiId: status.emojiId,
|
||||||
|
guildId: emoji?.guildId ?? null,
|
||||||
|
guild: emoji?.guildId ? GuildStore.getGuild(emoji.guildId) : null,
|
||||||
|
emojiName: status.emojiName,
|
||||||
|
});
|
||||||
|
|
||||||
const getEmojiDisplayName = (): string => {
|
const getEmojiDisplayName = (): string => {
|
||||||
if (status.emojiId) {
|
if (status.emojiId) {
|
||||||
@@ -257,7 +206,18 @@ const StatusEmojiWithTooltip = observer(
|
|||||||
emoji={shouldUseNativeEmoji && status.emojiName && !status.emojiId ? status.emojiName : undefined}
|
emoji={shouldUseNativeEmoji && status.emojiName && !status.emojiId ? status.emojiName : undefined}
|
||||||
emojiAlt={status.emojiName ?? undefined}
|
emojiAlt={status.emojiName ?? undefined}
|
||||||
primaryContent={emojiName}
|
primaryContent={emojiName}
|
||||||
subtext={<StatusEmojiTooltipSubtext status={status} />}
|
subtext={
|
||||||
|
<EmojiAttributionSubtext
|
||||||
|
attribution={attribution}
|
||||||
|
classes={{
|
||||||
|
container: styles.emojiTooltipSubtext,
|
||||||
|
guildRow: styles.emojiTooltipGuildRow,
|
||||||
|
guildIcon: styles.emojiTooltipGuildIcon,
|
||||||
|
guildName: styles.emojiTooltipGuildName,
|
||||||
|
verifiedIcon: styles.emojiTooltipVerifiedIcon,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
152
fluxer_app/src/components/emojis/EmojiAttributionSubtext.tsx
Normal file
152
fluxer_app/src/components/emojis/EmojiAttributionSubtext.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Fluxer Contributors
|
||||||
|
*
|
||||||
|
* This file is part of Fluxer.
|
||||||
|
*
|
||||||
|
* Fluxer is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Fluxer 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 Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {Trans, useLingui} from '@lingui/react/macro';
|
||||||
|
import {SealCheckIcon} from '@phosphor-icons/react';
|
||||||
|
import {observer} from 'mobx-react-lite';
|
||||||
|
import {GuildIcon} from '~/components/popouts/GuildIcon';
|
||||||
|
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||||
|
import type {Guild, GuildRecord} from '~/records/GuildRecord';
|
||||||
|
import GuildListStore from '~/stores/GuildListStore';
|
||||||
|
import GuildStore from '~/stores/GuildStore';
|
||||||
|
|
||||||
|
type EmojiAttributionType = 'default' | 'custom_invite_required' | 'custom_unknown' | 'custom_guild';
|
||||||
|
type EmojiGuild = Guild | GuildRecord;
|
||||||
|
|
||||||
|
export interface EmojiAttribution {
|
||||||
|
type: EmojiAttributionType;
|
||||||
|
guild?: EmojiGuild | null;
|
||||||
|
isVerified?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmojiAttributionSource {
|
||||||
|
emojiId?: string | null;
|
||||||
|
guildId?: string | null;
|
||||||
|
guild?: EmojiGuild | null;
|
||||||
|
emojiName?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getIsVerified = (guild?: EmojiGuild | null): boolean => {
|
||||||
|
if (!guild) return false;
|
||||||
|
const features = (guild as GuildRecord).features ?? (guild as Guild).features;
|
||||||
|
if (!features) return false;
|
||||||
|
if (Array.isArray(features)) {
|
||||||
|
return features.includes('VERIFIED');
|
||||||
|
}
|
||||||
|
if (features instanceof Set) {
|
||||||
|
return features.has('VERIFIED');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEmojiAttribution = ({emojiId, guildId, guild}: EmojiAttributionSource): EmojiAttribution => {
|
||||||
|
if (!emojiId) {
|
||||||
|
return {type: 'default'};
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedGuild = guildId ? (guild ?? GuildStore.getGuild(guildId)) : null;
|
||||||
|
const isVerified = getIsVerified(resolvedGuild);
|
||||||
|
|
||||||
|
if (resolvedGuild) {
|
||||||
|
return {type: 'custom_guild', guild: resolvedGuild, isVerified};
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMember = guildId ? GuildListStore.guilds.some((candidate) => candidate.id === guildId) : null;
|
||||||
|
|
||||||
|
if (isMember === false) {
|
||||||
|
return {type: 'custom_invite_required'};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {type: 'custom_unknown'};
|
||||||
|
};
|
||||||
|
|
||||||
|
interface EmojiAttributionSubtextProps {
|
||||||
|
attribution: EmojiAttribution;
|
||||||
|
classes?: {
|
||||||
|
container?: string;
|
||||||
|
text?: string;
|
||||||
|
guildRow?: string;
|
||||||
|
guildIcon?: string;
|
||||||
|
guildName?: string;
|
||||||
|
verifiedIcon?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EmojiAttributionSubtext = observer(function EmojiAttributionSubtext({
|
||||||
|
attribution,
|
||||||
|
classes = {},
|
||||||
|
}: EmojiAttributionSubtextProps) {
|
||||||
|
const {t} = useLingui();
|
||||||
|
|
||||||
|
if (attribution.type === 'default') {
|
||||||
|
return (
|
||||||
|
<div className={classes.container}>
|
||||||
|
<span className={classes.text}>
|
||||||
|
<Trans>This is a default emoji on Fluxer.</Trans>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attribution.type === 'custom_invite_required') {
|
||||||
|
return (
|
||||||
|
<div className={classes.container}>
|
||||||
|
<span className={classes.text}>
|
||||||
|
<Trans>This is a custom emoji from a community. Ask the author for an invite to use this emoji.</Trans>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attribution.type === 'custom_unknown' || !attribution.guild) {
|
||||||
|
return (
|
||||||
|
<div className={classes.container}>
|
||||||
|
<span className={classes.text}>
|
||||||
|
<Trans>This is a custom emoji from a community.</Trans>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.container}>
|
||||||
|
<span className={classes.text}>
|
||||||
|
<Trans>This is a custom emoji from</Trans>
|
||||||
|
</span>
|
||||||
|
<div className={classes.guildRow}>
|
||||||
|
<div className={classes.guildIcon}>
|
||||||
|
<GuildIcon
|
||||||
|
id={attribution.guild.id}
|
||||||
|
name={attribution.guild.name}
|
||||||
|
icon={attribution.guild.icon}
|
||||||
|
sizePx={20}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className={classes.guildName}>{attribution.guild.name}</span>
|
||||||
|
{attribution.isVerified && (
|
||||||
|
<Tooltip text={t`Verified Community`} position="top">
|
||||||
|
<SealCheckIcon className={classes.verifiedIcon} />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
EmojiAttributionSubtext.displayName = 'EmojiAttributionSubtext';
|
||||||
@@ -17,14 +17,9 @@
|
|||||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {Trans, useLingui} from '@lingui/react/macro';
|
|
||||||
import {SealCheckIcon} from '@phosphor-icons/react';
|
|
||||||
import {observer} from 'mobx-react-lite';
|
import {observer} from 'mobx-react-lite';
|
||||||
import {GuildFeatures} from '~/Constants';
|
import {EmojiAttributionSubtext, getEmojiAttribution} from '~/components/emojis/EmojiAttributionSubtext';
|
||||||
import {GuildIcon} from '~/components/popouts/GuildIcon';
|
|
||||||
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
|
||||||
import type {Emoji} from '~/stores/EmojiStore';
|
import type {Emoji} from '~/stores/EmojiStore';
|
||||||
import GuildListStore from '~/stores/GuildListStore';
|
|
||||||
import GuildStore from '~/stores/GuildStore';
|
import GuildStore from '~/stores/GuildStore';
|
||||||
import styles from './EmojiInfoContent.module.css';
|
import styles from './EmojiInfoContent.module.css';
|
||||||
|
|
||||||
@@ -33,62 +28,25 @@ interface EmojiInfoContentProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const EmojiInfoContent = observer(function EmojiInfoContent({emoji}: EmojiInfoContentProps) {
|
export const EmojiInfoContent = observer(function EmojiInfoContent({emoji}: EmojiInfoContentProps) {
|
||||||
const {t} = useLingui();
|
const guild = emoji.guildId ? GuildStore.getGuild(emoji.guildId) : null;
|
||||||
const isCustomEmoji = Boolean(emoji.guildId || emoji.id);
|
const attribution = getEmojiAttribution({
|
||||||
|
emojiId: emoji.id,
|
||||||
if (!isCustomEmoji) {
|
guildId: emoji.guildId,
|
||||||
return (
|
guild,
|
||||||
<div className={styles.container}>
|
emojiName: emoji.name,
|
||||||
<span className={styles.text}>
|
});
|
||||||
<Trans>This is a default emoji on Fluxer.</Trans>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const guildId = emoji.guildId;
|
|
||||||
const isMember = guildId ? GuildListStore.guilds.some((guild) => guild.id === guildId) : false;
|
|
||||||
|
|
||||||
if (!isMember) {
|
|
||||||
return (
|
|
||||||
<div className={styles.container}>
|
|
||||||
<span className={styles.text}>
|
|
||||||
<Trans>This is a custom emoji from a community. Ask the author for an invite to use this emoji.</Trans>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const guild = guildId ? GuildStore.getGuild(guildId) : null;
|
|
||||||
|
|
||||||
if (!guild) {
|
|
||||||
return (
|
|
||||||
<div className={styles.container}>
|
|
||||||
<span className={styles.text}>
|
|
||||||
<Trans>This is a custom emoji from a community.</Trans>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isVerified = guild.features.has(GuildFeatures.VERIFIED);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<EmojiAttributionSubtext
|
||||||
<span className={styles.text}>
|
attribution={attribution}
|
||||||
<Trans>This is a custom emoji from</Trans>
|
classes={{
|
||||||
</span>
|
container: styles.container,
|
||||||
<div className={styles.guildRow}>
|
text: styles.text,
|
||||||
<div className={styles.guildIcon}>
|
guildRow: styles.guildRow,
|
||||||
<GuildIcon id={guild.id} name={guild.name} icon={guild.icon} sizePx={20} />
|
guildIcon: styles.guildIcon,
|
||||||
</div>
|
guildName: styles.guildName,
|
||||||
<span className={styles.guildName}>{guild.name}</span>
|
verifiedIcon: styles.verifiedIcon,
|
||||||
{isVerified && (
|
}}
|
||||||
<Tooltip text={t`Verified Community`} position="top">
|
/>
|
||||||
<SealCheckIcon className={styles.verifiedIcon} />
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -242,6 +242,11 @@
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.channelItemDisabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.hoverAffordance {
|
.hoverAffordance {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ import ReadStateStore from '~/stores/ReadStateStore';
|
|||||||
import SelectedChannelStore from '~/stores/SelectedChannelStore';
|
import SelectedChannelStore from '~/stores/SelectedChannelStore';
|
||||||
import TrustedDomainStore from '~/stores/TrustedDomainStore';
|
import TrustedDomainStore from '~/stores/TrustedDomainStore';
|
||||||
import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore';
|
import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore';
|
||||||
|
import UserStore from '~/stores/UserStore';
|
||||||
import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
|
import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
|
||||||
|
|
||||||
import * as ChannelUtils from '~/utils/ChannelUtils';
|
import * as ChannelUtils from '~/utils/ChannelUtils';
|
||||||
@@ -194,10 +195,18 @@ export const ChannelItem = observer(
|
|||||||
|
|
||||||
const showKeyboardAffordances = keyboardModeEnabled && isFocused;
|
const showKeyboardAffordances = keyboardModeEnabled && isFocused;
|
||||||
const currentUserId = AuthenticationStore.currentUserId;
|
const currentUserId = AuthenticationStore.currentUserId;
|
||||||
|
const currentUser = UserStore.getCurrentUser();
|
||||||
|
const isUnclaimed = !(currentUser?.isClaimed() ?? false);
|
||||||
|
const isGuildOwner = currentUser ? guild.isOwner(currentUser.id) : false;
|
||||||
const currentMember = currentUserId ? GuildMemberStore.getMember(guild.id, currentUserId) : null;
|
const currentMember = currentUserId ? GuildMemberStore.getMember(guild.id, currentUserId) : null;
|
||||||
const isCurrentUserTimedOut = Boolean(currentMember?.isTimedOut());
|
const isCurrentUserTimedOut = Boolean(currentMember?.isTimedOut());
|
||||||
|
const voiceBlockedForUnclaimed = channelIsVoice && isUnclaimed && !isGuildOwner;
|
||||||
const voiceTooltipText =
|
const voiceTooltipText =
|
||||||
channelIsVoice && isCurrentUserTimedOut ? t`You can't join while you're on timeout.` : undefined;
|
channelIsVoice && isCurrentUserTimedOut
|
||||||
|
? t`You can't join while you're on timeout.`
|
||||||
|
: channelIsVoice && voiceBlockedForUnclaimed
|
||||||
|
? t`Claim your account to join this voice channel.`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const isVoiceSelected =
|
const isVoiceSelected =
|
||||||
channel.type === ChannelTypes.GUILD_VOICE &&
|
channel.type === ChannelTypes.GUILD_VOICE &&
|
||||||
@@ -367,6 +376,13 @@ export const ChannelItem = observer(
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (channel.type === ChannelTypes.GUILD_VOICE && voiceBlockedForUnclaimed) {
|
||||||
|
ToastActionCreators.createToast({
|
||||||
|
type: 'error',
|
||||||
|
children: t`Claim your account to join this voice channel.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (channel.type === ChannelTypes.GUILD_CATEGORY) {
|
if (channel.type === ChannelTypes.GUILD_CATEGORY) {
|
||||||
onToggle?.();
|
onToggle?.();
|
||||||
return;
|
return;
|
||||||
@@ -512,6 +528,7 @@ export const ChannelItem = observer(
|
|||||||
contextMenuOpen && styles.contextMenuOpen,
|
contextMenuOpen && styles.contextMenuOpen,
|
||||||
showKeyboardAffordances && styles.keyboardFocus,
|
showKeyboardAffordances && styles.keyboardFocus,
|
||||||
channelIsVoice && styles.channelItemVoice,
|
channelIsVoice && styles.channelItemVoice,
|
||||||
|
voiceBlockedForUnclaimed && styles.channelItemDisabled,
|
||||||
)}
|
)}
|
||||||
onClick={handleSelect}
|
onClick={handleSelect}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
|
|||||||
@@ -200,6 +200,10 @@ export const GuildLayout = observer(({children}: {children: React.ReactNode}) =>
|
|||||||
if (nagbarState.forceHideInvitesDisabled) return false;
|
if (nagbarState.forceHideInvitesDisabled) return false;
|
||||||
if (nagbarState.forceInvitesDisabled) return true;
|
if (nagbarState.forceInvitesDisabled) return true;
|
||||||
|
|
||||||
|
if (user && !user.isClaimed() && guild.ownerId === user.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const hasInvitesDisabled = guild.features.has(GuildFeatures.INVITES_DISABLED);
|
const hasInvitesDisabled = guild.features.has(GuildFeatures.INVITES_DISABLED);
|
||||||
if (!hasInvitesDisabled) return false;
|
if (!hasInvitesDisabled) return false;
|
||||||
|
|
||||||
@@ -218,6 +222,7 @@ export const GuildLayout = observer(({children}: {children: React.ReactNode}) =>
|
|||||||
invitesDisabledDismissed,
|
invitesDisabledDismissed,
|
||||||
nagbarState.forceInvitesDisabled,
|
nagbarState.forceInvitesDisabled,
|
||||||
nagbarState.forceHideInvitesDisabled,
|
nagbarState.forceHideInvitesDisabled,
|
||||||
|
user,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const shouldShowStaffOnlyGuild = React.useMemo(() => {
|
const shouldShowStaffOnlyGuild = React.useMemo(() => {
|
||||||
|
|||||||
@@ -28,11 +28,9 @@ import {clsx} from 'clsx';
|
|||||||
import {observer} from 'mobx-react-lite';
|
import {observer} from 'mobx-react-lite';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import * as DimensionActionCreators from '~/actions/DimensionActionCreators';
|
import * as DimensionActionCreators from '~/actions/DimensionActionCreators';
|
||||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
|
||||||
import {modal} from '~/actions/ModalActionCreators';
|
|
||||||
import * as UserSettingsActionCreators from '~/actions/UserSettingsActionCreators';
|
import * as UserSettingsActionCreators from '~/actions/UserSettingsActionCreators';
|
||||||
import {ChannelTypes} from '~/Constants';
|
import {ChannelTypes} from '~/Constants';
|
||||||
import {ClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
||||||
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||||
import {ComponentDispatch} from '~/lib/ComponentDispatch';
|
import {ComponentDispatch} from '~/lib/ComponentDispatch';
|
||||||
import {Platform} from '~/lib/Platform';
|
import {Platform} from '~/lib/Platform';
|
||||||
@@ -330,7 +328,7 @@ export const GuildsLayout = observer(({children}: {children: React.ReactNode}) =
|
|||||||
if (accountAgeMs < THIRTY_MINUTES_MS) return;
|
if (accountAgeMs < THIRTY_MINUTES_MS) return;
|
||||||
|
|
||||||
NagbarStore.markClaimAccountModalShown();
|
NagbarStore.markClaimAccountModalShown();
|
||||||
ModalActionCreators.push(modal(() => <ClaimAccountModal />));
|
openClaimAccountModal();
|
||||||
}, [isReady, user, location.pathname]);
|
}, [isReady, user, location.pathname]);
|
||||||
|
|
||||||
const shouldShowSidebarDivider = !mobileLayout.enabled;
|
const shouldShowSidebarDivider = !mobileLayout.enabled;
|
||||||
|
|||||||
@@ -19,12 +19,10 @@
|
|||||||
|
|
||||||
import {Trans} from '@lingui/react/macro';
|
import {Trans} from '@lingui/react/macro';
|
||||||
import {observer} from 'mobx-react-lite';
|
import {observer} from 'mobx-react-lite';
|
||||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
|
||||||
import {modal} from '~/actions/ModalActionCreators';
|
|
||||||
import {Nagbar} from '~/components/layout/Nagbar';
|
import {Nagbar} from '~/components/layout/Nagbar';
|
||||||
import {NagbarButton} from '~/components/layout/NagbarButton';
|
import {NagbarButton} from '~/components/layout/NagbarButton';
|
||||||
import {NagbarContent} from '~/components/layout/NagbarContent';
|
import {NagbarContent} from '~/components/layout/NagbarContent';
|
||||||
import {ClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
||||||
import UserStore from '~/stores/UserStore';
|
import UserStore from '~/stores/UserStore';
|
||||||
|
|
||||||
export const UnclaimedAccountNagbar = observer(({isMobile}: {isMobile: boolean}) => {
|
export const UnclaimedAccountNagbar = observer(({isMobile}: {isMobile: boolean}) => {
|
||||||
@@ -34,7 +32,7 @@ export const UnclaimedAccountNagbar = observer(({isMobile}: {isMobile: boolean})
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleClaimAccount = () => {
|
const handleClaimAccount = () => {
|
||||||
ModalActionCreators.push(modal(() => <ClaimAccountModal />));
|
openClaimAccountModal({force: true});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
|||||||
import {ChannelTypes} from '~/Constants';
|
import {ChannelTypes} from '~/Constants';
|
||||||
import * as Modal from '~/components/modals/Modal';
|
import * as Modal from '~/components/modals/Modal';
|
||||||
import ChannelStore from '~/stores/ChannelStore';
|
import ChannelStore from '~/stores/ChannelStore';
|
||||||
|
import ConnectionStore from '~/stores/gateway/ConnectionStore';
|
||||||
import MobileLayoutStore from '~/stores/MobileLayoutStore';
|
import MobileLayoutStore from '~/stores/MobileLayoutStore';
|
||||||
import {isMobileExperienceEnabled} from '~/utils/mobileExperience';
|
import {isMobileExperienceEnabled} from '~/utils/mobileExperience';
|
||||||
import {
|
import {
|
||||||
@@ -41,6 +42,7 @@ import type {ChannelSettingsTabType} from './utils/channelSettingsConstants';
|
|||||||
export const ChannelSettingsModal: React.FC<ChannelSettingsModalProps> = observer(({channelId, initialMobileTab}) => {
|
export const ChannelSettingsModal: React.FC<ChannelSettingsModalProps> = observer(({channelId, initialMobileTab}) => {
|
||||||
const {t} = useLingui();
|
const {t} = useLingui();
|
||||||
const channel = ChannelStore.getChannel(channelId);
|
const channel = ChannelStore.getChannel(channelId);
|
||||||
|
const guildId = channel?.guildId;
|
||||||
const [selectedTab, setSelectedTab] = React.useState<ChannelSettingsTabType>('overview');
|
const [selectedTab, setSelectedTab] = React.useState<ChannelSettingsTabType>('overview');
|
||||||
|
|
||||||
const availableTabs = React.useMemo(() => {
|
const availableTabs = React.useMemo(() => {
|
||||||
@@ -59,6 +61,12 @@ export const ChannelSettingsModal: React.FC<ChannelSettingsModalProps> = observe
|
|||||||
const mobileNav = useMobileNavigation<ChannelSettingsTabType>(initialTab);
|
const mobileNav = useMobileNavigation<ChannelSettingsTabType>(initialTab);
|
||||||
const {enabled: isMobile} = MobileLayoutStore;
|
const {enabled: isMobile} = MobileLayoutStore;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (guildId) {
|
||||||
|
ConnectionStore.syncGuildIfNeeded(guildId, 'channel-settings-modal');
|
||||||
|
}
|
||||||
|
}, [guildId]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!channel) {
|
if (!channel) {
|
||||||
ModalActionCreators.pop();
|
ModalActionCreators.pop();
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {observer} from 'mobx-react-lite';
|
|||||||
import {useEffect, useMemo, useState} from 'react';
|
import {useEffect, useMemo, useState} from 'react';
|
||||||
import {useForm} from 'react-hook-form';
|
import {useForm} from 'react-hook-form';
|
||||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||||
|
import {modal} from '~/actions/ModalActionCreators';
|
||||||
import * as ToastActionCreators from '~/actions/ToastActionCreators';
|
import * as ToastActionCreators from '~/actions/ToastActionCreators';
|
||||||
import * as UserActionCreators from '~/actions/UserActionCreators';
|
import * as UserActionCreators from '~/actions/UserActionCreators';
|
||||||
import {Form} from '~/components/form/Form';
|
import {Form} from '~/components/form/Form';
|
||||||
@@ -31,6 +32,7 @@ import confirmStyles from '~/components/modals/ConfirmModal.module.css';
|
|||||||
import * as Modal from '~/components/modals/Modal';
|
import * as Modal from '~/components/modals/Modal';
|
||||||
import {Button} from '~/components/uikit/Button/Button';
|
import {Button} from '~/components/uikit/Button/Button';
|
||||||
import {useFormSubmit} from '~/hooks/useFormSubmit';
|
import {useFormSubmit} from '~/hooks/useFormSubmit';
|
||||||
|
import ModalStore from '~/stores/ModalStore';
|
||||||
|
|
||||||
interface FormInputs {
|
interface FormInputs {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -230,3 +232,20 @@ export const ClaimAccountModal = observer(() => {
|
|||||||
</Modal.Root>
|
</Modal.Root>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const CLAIM_ACCOUNT_MODAL_KEY = 'claim-account-modal';
|
||||||
|
let hasShownClaimAccountModalThisSession = false;
|
||||||
|
|
||||||
|
export const openClaimAccountModal = ({force = false}: {force?: boolean} = {}): void => {
|
||||||
|
if (ModalStore.hasModal(CLAIM_ACCOUNT_MODAL_KEY)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!force && hasShownClaimAccountModalThisSession) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hasShownClaimAccountModalThisSession = true;
|
||||||
|
ModalActionCreators.pushWithKey(
|
||||||
|
modal(() => <ClaimAccountModal />),
|
||||||
|
CLAIM_ACCOUNT_MODAL_KEY,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -23,12 +23,14 @@ import {observer} from 'mobx-react-lite';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import * as GiftActionCreators from '~/actions/GiftActionCreators';
|
import * as GiftActionCreators from '~/actions/GiftActionCreators';
|
||||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||||
|
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
||||||
import * as Modal from '~/components/modals/Modal';
|
import * as Modal from '~/components/modals/Modal';
|
||||||
import {Button} from '~/components/uikit/Button/Button';
|
import {Button} from '~/components/uikit/Button/Button';
|
||||||
import {Spinner} from '~/components/uikit/Spinner';
|
import {Spinner} from '~/components/uikit/Spinner';
|
||||||
import i18n from '~/i18n';
|
import i18n from '~/i18n';
|
||||||
import {UserRecord} from '~/records/UserRecord';
|
import {UserRecord} from '~/records/UserRecord';
|
||||||
import GiftStore from '~/stores/GiftStore';
|
import GiftStore from '~/stores/GiftStore';
|
||||||
|
import UserStore from '~/stores/UserStore';
|
||||||
import {getGiftDurationText} from '~/utils/giftUtils';
|
import {getGiftDurationText} from '~/utils/giftUtils';
|
||||||
import styles from './GiftAcceptModal.module.css';
|
import styles from './GiftAcceptModal.module.css';
|
||||||
|
|
||||||
@@ -41,6 +43,7 @@ export const GiftAcceptModal = observer(function GiftAcceptModal({code}: GiftAcc
|
|||||||
const giftState = GiftStore.gifts.get(code) ?? null;
|
const giftState = GiftStore.gifts.get(code) ?? null;
|
||||||
const gift = giftState?.data ?? null;
|
const gift = giftState?.data ?? null;
|
||||||
const [isRedeeming, setIsRedeeming] = React.useState(false);
|
const [isRedeeming, setIsRedeeming] = React.useState(false);
|
||||||
|
const isUnclaimed = !(UserStore.currentUser?.isClaimed() ?? false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!giftState) {
|
if (!giftState) {
|
||||||
@@ -64,6 +67,10 @@ export const GiftAcceptModal = observer(function GiftAcceptModal({code}: GiftAcc
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRedeem = async () => {
|
const handleRedeem = async () => {
|
||||||
|
if (isUnclaimed) {
|
||||||
|
openClaimAccountModal({force: true});
|
||||||
|
return;
|
||||||
|
}
|
||||||
setIsRedeeming(true);
|
setIsRedeeming(true);
|
||||||
try {
|
try {
|
||||||
await GiftActionCreators.redeem(i18n, code);
|
await GiftActionCreators.redeem(i18n, code);
|
||||||
@@ -130,6 +137,42 @@ export const GiftAcceptModal = observer(function GiftAcceptModal({code}: GiftAcc
|
|||||||
|
|
||||||
const renderGift = () => {
|
const renderGift = () => {
|
||||||
const durationText = getGiftDurationText(i18n, gift!);
|
const durationText = getGiftDurationText(i18n, gift!);
|
||||||
|
if (isUnclaimed) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.card}>
|
||||||
|
<div className={styles.cardGrid}>
|
||||||
|
<div className={`${styles.iconCircle} ${styles.iconCircleInactive}`}>
|
||||||
|
<GiftIcon className={styles.icon} weight="fill" />
|
||||||
|
</div>
|
||||||
|
<div className={styles.cardContent}>
|
||||||
|
<h3 className={`${styles.title} ${styles.titlePrimary}`}>{durationText}</h3>
|
||||||
|
{creator && (
|
||||||
|
<span className={styles.subtitle}>{t`From ${creator.username}#${creator.discriminator}`}</span>
|
||||||
|
)}
|
||||||
|
<span className={styles.helpText}>
|
||||||
|
<Trans>Claim your account to redeem this gift.</Trans>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.footer}>
|
||||||
|
<Button variant="secondary" onClick={handleDismiss}>
|
||||||
|
<Trans>Maybe later</Trans>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => {
|
||||||
|
openClaimAccountModal({force: true});
|
||||||
|
handleDismiss();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trans>Claim Account</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={styles.card}>
|
<div className={styles.card}>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import * as UnsavedChangesActionCreators from '~/actions/UnsavedChangesActionCre
|
|||||||
import * as Modal from '~/components/modals/Modal';
|
import * as Modal from '~/components/modals/Modal';
|
||||||
import GuildSettingsModalStore from '~/stores/GuildSettingsModalStore';
|
import GuildSettingsModalStore from '~/stores/GuildSettingsModalStore';
|
||||||
import GuildStore from '~/stores/GuildStore';
|
import GuildStore from '~/stores/GuildStore';
|
||||||
|
import ConnectionStore from '~/stores/gateway/ConnectionStore';
|
||||||
import MobileLayoutStore from '~/stores/MobileLayoutStore';
|
import MobileLayoutStore from '~/stores/MobileLayoutStore';
|
||||||
import PermissionStore from '~/stores/PermissionStore';
|
import PermissionStore from '~/stores/PermissionStore';
|
||||||
import UnsavedChangesStore from '~/stores/UnsavedChangesStore';
|
import UnsavedChangesStore from '~/stores/UnsavedChangesStore';
|
||||||
@@ -79,6 +80,10 @@ export const GuildSettingsModal: React.FC<GuildSettingsModalProps> = observer(
|
|||||||
|
|
||||||
const unsavedChangesStore = UnsavedChangesStore;
|
const unsavedChangesStore = UnsavedChangesStore;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
ConnectionStore.syncGuildIfNeeded(guildId, 'guild-settings-modal');
|
||||||
|
}, [guildId]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!guild) {
|
if (!guild) {
|
||||||
ModalActionCreators.pop();
|
ModalActionCreators.pop();
|
||||||
|
|||||||
@@ -230,6 +230,17 @@ export const InviteModal = observer(({channelId}: {channelId: string}) => {
|
|||||||
<span className={styles.channelName}>{channel.name}</span>
|
<span className={styles.channelName}>{channel.name}</span>
|
||||||
</Trans>
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
|
{invitesDisabled && (
|
||||||
|
<div className={styles.warningContainer}>
|
||||||
|
<WarningCircleIcon className={styles.warningIcon} weight="fill" />
|
||||||
|
<p className={styles.warningText}>
|
||||||
|
<Trans>
|
||||||
|
Invites are currently disabled in this community by an admin. While this invite can be created, it
|
||||||
|
cannot be accepted until invites are re-enabled.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className={selectorStyles.headerSearch}>
|
<div className={selectorStyles.headerSearch}>
|
||||||
<Input
|
<Input
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
@@ -248,33 +259,19 @@ export const InviteModal = observer(({channelId}: {channelId: string}) => {
|
|||||||
<Spinner />
|
<Spinner />
|
||||||
</div>
|
</div>
|
||||||
) : !showAdvanced ? (
|
) : !showAdvanced ? (
|
||||||
<>
|
<RecipientList
|
||||||
<RecipientList
|
recipients={recipients}
|
||||||
recipients={recipients}
|
sendingTo={sendingTo}
|
||||||
sendingTo={sendingTo}
|
sentTo={sentInvites}
|
||||||
sentTo={sentInvites}
|
onSend={handleSendInvite}
|
||||||
onSend={handleSendInvite}
|
defaultButtonLabel={t`Invite`}
|
||||||
defaultButtonLabel={t`Invite`}
|
sentButtonLabel={t`Sent`}
|
||||||
sentButtonLabel={t`Sent`}
|
buttonClassName={styles.inviteButton}
|
||||||
buttonClassName={styles.inviteButton}
|
scrollerKey="invite-modal-friend-list-scroller"
|
||||||
scrollerKey="invite-modal-friend-list-scroller"
|
searchQuery={searchQuery}
|
||||||
searchQuery={searchQuery}
|
onSearchQueryChange={setSearchQuery}
|
||||||
onSearchQueryChange={setSearchQuery}
|
showSearchInput={false}
|
||||||
showSearchInput={false}
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
{invitesDisabled && (
|
|
||||||
<div className={styles.warningContainer}>
|
|
||||||
<WarningCircleIcon className={styles.warningIcon} weight="fill" />
|
|
||||||
<p className={styles.warningText}>
|
|
||||||
<Trans>
|
|
||||||
Invites are currently disabled in this community by an admin. While this invite can be created, it
|
|
||||||
cannot be accepted until invites are re-enabled.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.advancedView}>
|
<div className={styles.advancedView}>
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ const UserProfileMobileSheetContent: React.FC<UserProfileMobileSheetContentProps
|
|||||||
const isCurrentUser = user.id === AuthenticationStore.currentUserId;
|
const isCurrentUser = user.id === AuthenticationStore.currentUserId;
|
||||||
const relationship = RelationshipStore.getRelationship(user.id);
|
const relationship = RelationshipStore.getRelationship(user.id);
|
||||||
const relationshipType = relationship?.type;
|
const relationshipType = relationship?.type;
|
||||||
|
const currentUserUnclaimed = !(UserStore.currentUser?.isClaimed() ?? true);
|
||||||
|
|
||||||
const guildMember = GuildMemberStore.getMember(profile?.guildId ?? guildId ?? '', user.id);
|
const guildMember = GuildMemberStore.getMember(profile?.guildId ?? guildId ?? '', user.id);
|
||||||
const memberRoles = profile?.guildId && guildMember ? guildMember.getSortedRoles() : [];
|
const memberRoles = profile?.guildId && guildMember ? guildMember.getSortedRoles() : [];
|
||||||
@@ -389,11 +390,14 @@ const UserProfileMobileSheetContent: React.FC<UserProfileMobileSheetContentProps
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
if (relationshipType === undefined && !currentUserUnclaimed) {
|
||||||
<button type="button" onClick={handleSendFriendRequest} className={styles.actionButton}>
|
return (
|
||||||
<UserPlusIcon className={styles.icon} />
|
<button type="button" onClick={handleSendFriendRequest} className={styles.actionButton}>
|
||||||
</button>
|
<UserPlusIcon className={styles.icon} />
|
||||||
);
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1164,6 +1164,7 @@ export const UserProfileModal: UserProfileModalComponent = observer(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderActionButtons = () => {
|
const renderActionButtons = () => {
|
||||||
|
const currentUserUnclaimed = !(UserStore.currentUser?.isClaimed() ?? true);
|
||||||
if (isCurrentUser && disableEditProfile) {
|
if (isCurrentUser && disableEditProfile) {
|
||||||
return (
|
return (
|
||||||
<div className={userProfileModalStyles.actionButtons}>
|
<div className={userProfileModalStyles.actionButtons}>
|
||||||
@@ -1284,8 +1285,11 @@ export const UserProfileModal: UserProfileModalComponent = observer(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (relationshipType === undefined && !isUserBot) {
|
if (relationshipType === undefined && !isUserBot) {
|
||||||
|
const tooltipText = currentUserUnclaimed
|
||||||
|
? t`Claim your account to send friend requests.`
|
||||||
|
: t`Send Friend Request`;
|
||||||
return (
|
return (
|
||||||
<Tooltip text={t`Send Friend Request`} maxWidth="xl">
|
<Tooltip text={tooltipText} maxWidth="xl">
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -1293,6 +1297,7 @@ export const UserProfileModal: UserProfileModalComponent = observer(
|
|||||||
square={true}
|
square={true}
|
||||||
icon={<UserPlusIcon className={userProfileModalStyles.buttonIcon} />}
|
icon={<UserPlusIcon className={userProfileModalStyles.buttonIcon} />}
|
||||||
onClick={handleSendFriendRequest}
|
onClick={handleSendFriendRequest}
|
||||||
|
disabled={currentUserUnclaimed}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -32,11 +32,9 @@ import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
|||||||
import {ClientInfo} from '~/components/modals/components/ClientInfo';
|
import {ClientInfo} from '~/components/modals/components/ClientInfo';
|
||||||
import {LogoutModal} from '~/components/modals/components/LogoutModal';
|
import {LogoutModal} from '~/components/modals/components/LogoutModal';
|
||||||
import styles from '~/components/modals/components/MobileSettingsView.module.css';
|
import styles from '~/components/modals/components/MobileSettingsView.module.css';
|
||||||
import {ScrollSpyProvider, useScrollSpyContext} from '~/components/modals/hooks/ScrollSpyContext';
|
|
||||||
import type {MobileNavigationState} from '~/components/modals/hooks/useMobileNavigation';
|
import type {MobileNavigationState} from '~/components/modals/hooks/useMobileNavigation';
|
||||||
import {useSettingsContentKey} from '~/components/modals/hooks/useSettingsContentKey';
|
import {useSettingsContentKey} from '~/components/modals/hooks/useSettingsContentKey';
|
||||||
import {
|
import {
|
||||||
MobileSectionNav,
|
|
||||||
MobileSettingsDangerItem,
|
MobileSettingsDangerItem,
|
||||||
MobileHeader as SharedMobileHeader,
|
MobileHeader as SharedMobileHeader,
|
||||||
} from '~/components/modals/shared/MobileSettingsComponents';
|
} from '~/components/modals/shared/MobileSettingsComponents';
|
||||||
@@ -44,16 +42,13 @@ import userSettingsStyles from '~/components/modals/UserSettingsModal.module.css
|
|||||||
import {getSettingsTabComponent} from '~/components/modals/utils/desktopSettingsTabs';
|
import {getSettingsTabComponent} from '~/components/modals/utils/desktopSettingsTabs';
|
||||||
import {
|
import {
|
||||||
getCategoryLabel,
|
getCategoryLabel,
|
||||||
getSectionIdsForTab,
|
|
||||||
getSectionsForTab,
|
|
||||||
type SettingsTab,
|
type SettingsTab,
|
||||||
tabHasSections,
|
|
||||||
type UserSettingsTabType,
|
type UserSettingsTabType,
|
||||||
} from '~/components/modals/utils/settingsConstants';
|
} from '~/components/modals/utils/settingsConstants';
|
||||||
import {filterSettingsTabsForDeveloperMode} from '~/components/modals/utils/settingsTabFilters';
|
import {filterSettingsTabsForDeveloperMode} from '~/components/modals/utils/settingsTabFilters';
|
||||||
import {Button} from '~/components/uikit/Button/Button';
|
import {Button} from '~/components/uikit/Button/Button';
|
||||||
import {MentionBadgeAnimated} from '~/components/uikit/MentionBadge';
|
import {MentionBadgeAnimated} from '~/components/uikit/MentionBadge';
|
||||||
import {Scroller, type ScrollerHandle} from '~/components/uikit/Scroller';
|
import {Scroller} from '~/components/uikit/Scroller';
|
||||||
import {Spinner} from '~/components/uikit/Spinner';
|
import {Spinner} from '~/components/uikit/Spinner';
|
||||||
import {usePressable} from '~/hooks/usePressable';
|
import {usePressable} from '~/hooks/usePressable';
|
||||||
import {usePushSubscriptions} from '~/hooks/usePushSubscriptions';
|
import {usePushSubscriptions} from '~/hooks/usePushSubscriptions';
|
||||||
@@ -397,26 +392,7 @@ const headerFadeVariants = {
|
|||||||
exit: {opacity: 0},
|
exit: {opacity: 0},
|
||||||
};
|
};
|
||||||
|
|
||||||
interface MobileSectionNavWrapperProps {
|
|
||||||
tabType: UserSettingsTabType;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MobileSectionNavWrapper: React.FC<MobileSectionNavWrapperProps> = observer(({tabType}) => {
|
|
||||||
const {t} = useLingui();
|
|
||||||
const scrollSpyContext = useScrollSpyContext();
|
|
||||||
const sections = getSectionsForTab(tabType, t);
|
|
||||||
|
|
||||||
if (!scrollSpyContext || sections.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {activeSectionId, scrollToSection} = scrollSpyContext;
|
|
||||||
|
|
||||||
return <MobileSectionNav sections={sections} activeSectionId={activeSectionId} onSectionClick={scrollToSection} />;
|
|
||||||
});
|
|
||||||
|
|
||||||
interface MobileContentWithScrollSpyProps {
|
interface MobileContentWithScrollSpyProps {
|
||||||
tabType: UserSettingsTabType;
|
|
||||||
scrollKey: string;
|
scrollKey: string;
|
||||||
initialGuildId?: string;
|
initialGuildId?: string;
|
||||||
initialSubtab?: string;
|
initialSubtab?: string;
|
||||||
@@ -424,21 +400,9 @@ interface MobileContentWithScrollSpyProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MobileContentWithScrollSpy: React.FC<MobileContentWithScrollSpyProps> = observer(
|
const MobileContentWithScrollSpy: React.FC<MobileContentWithScrollSpyProps> = observer(
|
||||||
({tabType, scrollKey, initialGuildId, initialSubtab, currentTabComponent}) => {
|
({scrollKey, initialGuildId, initialSubtab, currentTabComponent}) => {
|
||||||
const scrollerRef = React.useRef<ScrollerHandle | null>(null);
|
return (
|
||||||
const scrollContainerRef = React.useRef<HTMLElement | null>(null);
|
<Scroller className={styles.scrollerFlex} key={scrollKey} data-settings-scroll-container>
|
||||||
const sectionIds = React.useMemo(() => getSectionIdsForTab(tabType), [tabType]);
|
|
||||||
const hasSections = tabHasSections(tabType);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (scrollerRef.current) {
|
|
||||||
scrollContainerRef.current = scrollerRef.current.getScrollerNode();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const content = (
|
|
||||||
<Scroller ref={scrollerRef} className={styles.scrollerFlex} key={scrollKey} data-settings-scroll-container>
|
|
||||||
{hasSections && <MobileSectionNavWrapper tabType={tabType} />}
|
|
||||||
<div className={styles.contentContainer}>
|
<div className={styles.contentContainer}>
|
||||||
{currentTabComponent &&
|
{currentTabComponent &&
|
||||||
React.createElement(currentTabComponent, {
|
React.createElement(currentTabComponent, {
|
||||||
@@ -448,16 +412,6 @@ const MobileContentWithScrollSpy: React.FC<MobileContentWithScrollSpyProps> = ob
|
|||||||
</div>
|
</div>
|
||||||
</Scroller>
|
</Scroller>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hasSections) {
|
|
||||||
return (
|
|
||||||
<ScrollSpyProvider sectionIds={sectionIds} containerRef={scrollContainerRef}>
|
|
||||||
{content}
|
|
||||||
</ScrollSpyProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return content;
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -582,7 +536,6 @@ export const MobileSettingsView: React.FC<MobileSettingsViewProps> = observer(
|
|||||||
style={{willChange: 'transform'}}
|
style={{willChange: 'transform'}}
|
||||||
>
|
>
|
||||||
<MobileContentWithScrollSpy
|
<MobileContentWithScrollSpy
|
||||||
tabType={currentTab.type}
|
|
||||||
scrollKey={scrollKey}
|
scrollKey={scrollKey}
|
||||||
initialGuildId={initialGuildId}
|
initialGuildId={initialGuildId}
|
||||||
initialSubtab={initialSubtab}
|
initialSubtab={initialSubtab}
|
||||||
|
|||||||
@@ -94,6 +94,17 @@ export const PlutoniumContent: React.FC<{defaultGiftMode?: boolean}> = observer(
|
|||||||
mobileLayoutState.enabled,
|
mobileLayoutState.enabled,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isClaimed = currentUser?.isClaimed() ?? false;
|
||||||
|
const purchaseDisabled = !isClaimed;
|
||||||
|
const purchaseDisabledTooltip = <Trans>Claim your account to purchase Fluxer Plutonium.</Trans>;
|
||||||
|
const handleSelectPlanGuarded = React.useCallback(
|
||||||
|
(plan: 'monthly' | 'yearly' | 'visionary' | 'gift1Month' | 'gift1Year' | 'giftVisionary') => {
|
||||||
|
if (purchaseDisabled) return;
|
||||||
|
handleSelectPlan(plan);
|
||||||
|
},
|
||||||
|
[handleSelectPlan, purchaseDisabled],
|
||||||
|
);
|
||||||
|
|
||||||
const monthlyPrice = React.useMemo(() => getFormattedPrice(PricingTier.Monthly, countryCode), [countryCode]);
|
const monthlyPrice = React.useMemo(() => getFormattedPrice(PricingTier.Monthly, countryCode), [countryCode]);
|
||||||
const yearlyPrice = React.useMemo(() => getFormattedPrice(PricingTier.Yearly, countryCode), [countryCode]);
|
const yearlyPrice = React.useMemo(() => getFormattedPrice(PricingTier.Yearly, countryCode), [countryCode]);
|
||||||
const visionaryPrice = React.useMemo(() => getFormattedPrice(PricingTier.Visionary, countryCode), [countryCode]);
|
const visionaryPrice = React.useMemo(() => getFormattedPrice(PricingTier.Visionary, countryCode), [countryCode]);
|
||||||
@@ -221,12 +232,14 @@ export const PlutoniumContent: React.FC<{defaultGiftMode?: boolean}> = observer(
|
|||||||
scrollToPerks={scrollToPerks}
|
scrollToPerks={scrollToPerks}
|
||||||
handlePerksKeyDown={handlePerksKeyDown}
|
handlePerksKeyDown={handlePerksKeyDown}
|
||||||
navigateToRedeemGift={navigateToRedeemGift}
|
navigateToRedeemGift={navigateToRedeemGift}
|
||||||
handleSelectPlan={handleSelectPlan}
|
handleSelectPlan={handleSelectPlanGuarded}
|
||||||
handleOpenCustomerPortal={handleOpenCustomerPortal}
|
handleOpenCustomerPortal={handleOpenCustomerPortal}
|
||||||
handleReactivateSubscription={handleReactivateSubscription}
|
handleReactivateSubscription={handleReactivateSubscription}
|
||||||
handleCancelSubscription={handleCancelSubscription}
|
handleCancelSubscription={handleCancelSubscription}
|
||||||
handleCommunityButtonPointerDown={handleCommunityButtonPointerDown}
|
handleCommunityButtonPointerDown={handleCommunityButtonPointerDown}
|
||||||
handleCommunityButtonClick={handleCommunityButtonClick}
|
handleCommunityButtonClick={handleCommunityButtonClick}
|
||||||
|
purchaseDisabled={purchaseDisabled}
|
||||||
|
purchaseDisabledTooltip={purchaseDisabledTooltip}
|
||||||
/>
|
/>
|
||||||
<div className={styles.disclaimerContainer}>
|
<div className={styles.disclaimerContainer}>
|
||||||
<PurchaseDisclaimer align="center" isPremium />
|
<PurchaseDisclaimer align="center" isPremium />
|
||||||
@@ -245,7 +258,9 @@ export const PlutoniumContent: React.FC<{defaultGiftMode?: boolean}> = observer(
|
|||||||
loadingCheckout={loadingCheckout}
|
loadingCheckout={loadingCheckout}
|
||||||
loadingSlots={loadingSlots}
|
loadingSlots={loadingSlots}
|
||||||
isVisionarySoldOut={isVisionarySoldOut}
|
isVisionarySoldOut={isVisionarySoldOut}
|
||||||
handleSelectPlan={handleSelectPlan}
|
handleSelectPlan={handleSelectPlanGuarded}
|
||||||
|
purchaseDisabled={purchaseDisabled}
|
||||||
|
purchaseDisabledTooltip={purchaseDisabledTooltip}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<GiftSection
|
<GiftSection
|
||||||
@@ -257,7 +272,9 @@ export const PlutoniumContent: React.FC<{defaultGiftMode?: boolean}> = observer(
|
|||||||
loadingCheckout={loadingCheckout}
|
loadingCheckout={loadingCheckout}
|
||||||
loadingSlots={loadingSlots}
|
loadingSlots={loadingSlots}
|
||||||
isVisionarySoldOut={isVisionarySoldOut}
|
isVisionarySoldOut={isVisionarySoldOut}
|
||||||
handleSelectPlan={handleSelectPlan}
|
handleSelectPlan={handleSelectPlanGuarded}
|
||||||
|
purchaseDisabled={purchaseDisabled}
|
||||||
|
purchaseDisabledTooltip={purchaseDisabledTooltip}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -280,7 +297,9 @@ export const PlutoniumContent: React.FC<{defaultGiftMode?: boolean}> = observer(
|
|||||||
isGiftSubscription={subscriptionStatus.isGiftSubscription}
|
isGiftSubscription={subscriptionStatus.isGiftSubscription}
|
||||||
loadingCheckout={loadingCheckout}
|
loadingCheckout={loadingCheckout}
|
||||||
loadingSlots={loadingSlots}
|
loadingSlots={loadingSlots}
|
||||||
handleSelectPlan={handleSelectPlan}
|
handleSelectPlan={handleSelectPlanGuarded}
|
||||||
|
purchaseDisabled={purchaseDisabled}
|
||||||
|
purchaseDisabledTooltip={purchaseDisabledTooltip}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -302,7 +321,9 @@ export const PlutoniumContent: React.FC<{defaultGiftMode?: boolean}> = observer(
|
|||||||
loadingCheckout={loadingCheckout}
|
loadingCheckout={loadingCheckout}
|
||||||
loadingSlots={loadingSlots}
|
loadingSlots={loadingSlots}
|
||||||
isVisionarySoldOut={isVisionarySoldOut}
|
isVisionarySoldOut={isVisionarySoldOut}
|
||||||
handleSelectPlan={handleSelectPlan}
|
handleSelectPlan={handleSelectPlanGuarded}
|
||||||
|
purchaseDisabled={purchaseDisabled}
|
||||||
|
purchaseDisabledTooltip={purchaseDisabledTooltip}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,9 +19,7 @@
|
|||||||
|
|
||||||
import {Trans} from '@lingui/react/macro';
|
import {Trans} from '@lingui/react/macro';
|
||||||
import {observer} from 'mobx-react-lite';
|
import {observer} from 'mobx-react-lite';
|
||||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
||||||
import {modal} from '~/actions/ModalActionCreators';
|
|
||||||
import {ClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
|
||||||
import {Button} from '~/components/uikit/Button/Button';
|
import {Button} from '~/components/uikit/Button/Button';
|
||||||
import {WarningAlert} from '~/components/uikit/WarningAlert/WarningAlert';
|
import {WarningAlert} from '~/components/uikit/WarningAlert/WarningAlert';
|
||||||
|
|
||||||
@@ -30,7 +28,7 @@ export const UnclaimedAccountAlert = observer(() => {
|
|||||||
<WarningAlert
|
<WarningAlert
|
||||||
title={<Trans>Unclaimed Account</Trans>}
|
title={<Trans>Unclaimed Account</Trans>}
|
||||||
actions={
|
actions={
|
||||||
<Button small={true} onClick={() => ModalActionCreators.push(modal(() => <ClaimAccountModal />))}>
|
<Button small={true} onClick={() => openClaimAccountModal({force: true})}>
|
||||||
<Trans>Claim Account</Trans>
|
<Trans>Claim Account</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,12 +17,13 @@
|
|||||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {Trans} from '@lingui/react/macro';
|
import {Trans, useLingui} from '@lingui/react/macro';
|
||||||
import {observer} from 'mobx-react-lite';
|
import {observer} from 'mobx-react-lite';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import {Button} from '~/components/uikit/Button/Button';
|
import {Button} from '~/components/uikit/Button/Button';
|
||||||
import {PurchaseDisclaimer} from '../PurchaseDisclaimer';
|
import {PurchaseDisclaimer} from '../PurchaseDisclaimer';
|
||||||
import styles from './BottomCTASection.module.css';
|
import styles from './BottomCTASection.module.css';
|
||||||
|
import {PurchaseDisabledWrapper} from './PurchaseDisabledWrapper';
|
||||||
|
|
||||||
interface BottomCTASectionProps {
|
interface BottomCTASectionProps {
|
||||||
isGiftMode: boolean;
|
isGiftMode: boolean;
|
||||||
@@ -33,6 +34,8 @@ interface BottomCTASectionProps {
|
|||||||
loadingSlots: boolean;
|
loadingSlots: boolean;
|
||||||
isVisionarySoldOut: boolean;
|
isVisionarySoldOut: boolean;
|
||||||
handleSelectPlan: (plan: 'monthly' | 'yearly' | 'visionary' | 'gift1Month' | 'gift1Year' | 'giftVisionary') => void;
|
handleSelectPlan: (plan: 'monthly' | 'yearly' | 'visionary' | 'gift1Month' | 'gift1Year' | 'giftVisionary') => void;
|
||||||
|
purchaseDisabled?: boolean;
|
||||||
|
purchaseDisabledTooltip?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BottomCTASection: React.FC<BottomCTASectionProps> = observer(
|
export const BottomCTASection: React.FC<BottomCTASectionProps> = observer(
|
||||||
@@ -45,7 +48,12 @@ export const BottomCTASection: React.FC<BottomCTASectionProps> = observer(
|
|||||||
loadingSlots,
|
loadingSlots,
|
||||||
isVisionarySoldOut,
|
isVisionarySoldOut,
|
||||||
handleSelectPlan,
|
handleSelectPlan,
|
||||||
|
purchaseDisabled = false,
|
||||||
|
purchaseDisabledTooltip,
|
||||||
}) => {
|
}) => {
|
||||||
|
const {t} = useLingui();
|
||||||
|
const tooltipText: React.ReactNode = purchaseDisabledTooltip ?? t`Claim your account to purchase Fluxer Plutonium.`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<h2 className={styles.title}>
|
<h2 className={styles.title}>
|
||||||
@@ -54,63 +62,79 @@ export const BottomCTASection: React.FC<BottomCTASectionProps> = observer(
|
|||||||
<div className={styles.buttonContainer}>
|
<div className={styles.buttonContainer}>
|
||||||
{!isGiftMode ? (
|
{!isGiftMode ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
|
||||||
variant="secondary"
|
<Button
|
||||||
onClick={() => handleSelectPlan('monthly')}
|
variant="secondary"
|
||||||
submitting={loadingCheckout || loadingSlots}
|
onClick={() => handleSelectPlan('monthly')}
|
||||||
className={styles.button}
|
submitting={loadingCheckout || loadingSlots}
|
||||||
>
|
className={styles.button}
|
||||||
<Trans>Monthly {monthlyPrice}</Trans>
|
disabled={purchaseDisabled}
|
||||||
</Button>
|
>
|
||||||
<Button
|
<Trans>Monthly {monthlyPrice}</Trans>
|
||||||
variant="primary"
|
</Button>
|
||||||
onClick={() => handleSelectPlan('yearly')}
|
</PurchaseDisabledWrapper>
|
||||||
submitting={loadingCheckout || loadingSlots}
|
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
|
||||||
className={styles.button}
|
<Button
|
||||||
>
|
variant="primary"
|
||||||
<Trans>Yearly {yearlyPrice}</Trans>
|
onClick={() => handleSelectPlan('yearly')}
|
||||||
</Button>
|
submitting={loadingCheckout || loadingSlots}
|
||||||
<Button
|
className={styles.button}
|
||||||
variant="primary"
|
disabled={purchaseDisabled}
|
||||||
onClick={() => handleSelectPlan('visionary')}
|
>
|
||||||
submitting={loadingCheckout || loadingSlots}
|
<Trans>Yearly {yearlyPrice}</Trans>
|
||||||
disabled={isVisionarySoldOut}
|
</Button>
|
||||||
className={styles.button}
|
</PurchaseDisabledWrapper>
|
||||||
>
|
<PurchaseDisabledWrapper disabled={purchaseDisabled || isVisionarySoldOut} tooltipText={tooltipText}>
|
||||||
{isVisionarySoldOut ? <Trans>Visionary Sold Out</Trans> : <Trans>Visionary {visionaryPrice}</Trans>}
|
<Button
|
||||||
</Button>
|
variant="primary"
|
||||||
|
onClick={() => handleSelectPlan('visionary')}
|
||||||
|
submitting={loadingCheckout || loadingSlots}
|
||||||
|
disabled={purchaseDisabled || isVisionarySoldOut}
|
||||||
|
className={styles.button}
|
||||||
|
>
|
||||||
|
{isVisionarySoldOut ? <Trans>Visionary Sold Out</Trans> : <Trans>Visionary {visionaryPrice}</Trans>}
|
||||||
|
</Button>
|
||||||
|
</PurchaseDisabledWrapper>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Button
|
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
|
||||||
variant="secondary"
|
<Button
|
||||||
onClick={() => handleSelectPlan('gift1Year')}
|
variant="secondary"
|
||||||
submitting={loadingCheckout || loadingSlots}
|
onClick={() => handleSelectPlan('gift1Year')}
|
||||||
className={styles.button}
|
submitting={loadingCheckout || loadingSlots}
|
||||||
>
|
className={styles.button}
|
||||||
<Trans>1 Year {yearlyPrice}</Trans>
|
disabled={purchaseDisabled}
|
||||||
</Button>
|
>
|
||||||
<Button
|
<Trans>1 Year {yearlyPrice}</Trans>
|
||||||
variant="primary"
|
</Button>
|
||||||
onClick={() => handleSelectPlan('gift1Month')}
|
</PurchaseDisabledWrapper>
|
||||||
submitting={loadingCheckout || loadingSlots}
|
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
|
||||||
className={styles.button}
|
<Button
|
||||||
>
|
variant="primary"
|
||||||
<Trans>1 Month {monthlyPrice}</Trans>
|
onClick={() => handleSelectPlan('gift1Month')}
|
||||||
</Button>
|
submitting={loadingCheckout || loadingSlots}
|
||||||
<Button
|
className={styles.button}
|
||||||
variant="primary"
|
disabled={purchaseDisabled}
|
||||||
onClick={() => handleSelectPlan('giftVisionary')}
|
>
|
||||||
submitting={loadingCheckout || loadingSlots}
|
<Trans>1 Month {monthlyPrice}</Trans>
|
||||||
disabled={isVisionarySoldOut}
|
</Button>
|
||||||
className={styles.button}
|
</PurchaseDisabledWrapper>
|
||||||
>
|
<PurchaseDisabledWrapper disabled={purchaseDisabled || isVisionarySoldOut} tooltipText={tooltipText}>
|
||||||
{isVisionarySoldOut ? (
|
<Button
|
||||||
<Trans>Visionary Gift Sold Out</Trans>
|
variant="primary"
|
||||||
) : (
|
onClick={() => handleSelectPlan('giftVisionary')}
|
||||||
<Trans>Visionary {visionaryPrice}</Trans>
|
submitting={loadingCheckout || loadingSlots}
|
||||||
)}
|
disabled={purchaseDisabled || isVisionarySoldOut}
|
||||||
</Button>
|
className={styles.button}
|
||||||
|
>
|
||||||
|
{isVisionarySoldOut ? (
|
||||||
|
<Trans>Visionary Gift Sold Out</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Visionary {visionaryPrice}</Trans>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</PurchaseDisabledWrapper>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {PricingCard} from '../PricingCard';
|
|||||||
import gridStyles from '../PricingGrid.module.css';
|
import gridStyles from '../PricingGrid.module.css';
|
||||||
import {PurchaseDisclaimer} from '../PurchaseDisclaimer';
|
import {PurchaseDisclaimer} from '../PurchaseDisclaimer';
|
||||||
import styles from './GiftSection.module.css';
|
import styles from './GiftSection.module.css';
|
||||||
|
import {PurchaseDisabledWrapper} from './PurchaseDisabledWrapper';
|
||||||
import {SectionHeader} from './SectionHeader';
|
import {SectionHeader} from './SectionHeader';
|
||||||
|
|
||||||
interface GiftSectionProps {
|
interface GiftSectionProps {
|
||||||
@@ -38,6 +39,8 @@ interface GiftSectionProps {
|
|||||||
loadingSlots: boolean;
|
loadingSlots: boolean;
|
||||||
isVisionarySoldOut: boolean;
|
isVisionarySoldOut: boolean;
|
||||||
handleSelectPlan: (plan: 'gift1Month' | 'gift1Year' | 'giftVisionary') => void;
|
handleSelectPlan: (plan: 'gift1Month' | 'gift1Year' | 'giftVisionary') => void;
|
||||||
|
purchaseDisabled?: boolean;
|
||||||
|
purchaseDisabledTooltip?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GiftSection: React.FC<GiftSectionProps> = observer(
|
export const GiftSection: React.FC<GiftSectionProps> = observer(
|
||||||
@@ -51,8 +54,11 @@ export const GiftSection: React.FC<GiftSectionProps> = observer(
|
|||||||
loadingSlots,
|
loadingSlots,
|
||||||
isVisionarySoldOut,
|
isVisionarySoldOut,
|
||||||
handleSelectPlan,
|
handleSelectPlan,
|
||||||
|
purchaseDisabled = false,
|
||||||
|
purchaseDisabledTooltip,
|
||||||
}) => {
|
}) => {
|
||||||
const {t} = useLingui();
|
const {t} = useLingui();
|
||||||
|
const tooltipText: React.ReactNode = purchaseDisabledTooltip ?? t`Claim your account to purchase Fluxer Plutonium.`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={giftSectionRef}>
|
<div ref={giftSectionRef}>
|
||||||
@@ -65,35 +71,43 @@ export const GiftSection: React.FC<GiftSectionProps> = observer(
|
|||||||
/>
|
/>
|
||||||
<div className={gridStyles.gridWrapper}>
|
<div className={gridStyles.gridWrapper}>
|
||||||
<div className={gridStyles.gridThreeColumns}>
|
<div className={gridStyles.gridThreeColumns}>
|
||||||
<PricingCard
|
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
|
||||||
title={t`1 Year Gift`}
|
<PricingCard
|
||||||
price={yearlyPrice}
|
title={t`1 Year Gift`}
|
||||||
period={t`one-time purchase`}
|
price={yearlyPrice}
|
||||||
badge={t`Save 17%`}
|
period={t`one-time purchase`}
|
||||||
onSelect={() => handleSelectPlan('gift1Year')}
|
badge={t`Save 17%`}
|
||||||
buttonText={t`Buy Gift`}
|
onSelect={() => handleSelectPlan('gift1Year')}
|
||||||
isLoading={loadingCheckout || loadingSlots}
|
buttonText={t`Buy Gift`}
|
||||||
/>
|
isLoading={loadingCheckout || loadingSlots}
|
||||||
<PricingCard
|
disabled={purchaseDisabled}
|
||||||
title={t`1 Month Gift`}
|
/>
|
||||||
price={monthlyPrice}
|
</PurchaseDisabledWrapper>
|
||||||
period={t`one-time purchase`}
|
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
|
||||||
isPopular
|
<PricingCard
|
||||||
onSelect={() => handleSelectPlan('gift1Month')}
|
title={t`1 Month Gift`}
|
||||||
buttonText={t`Buy Gift`}
|
price={monthlyPrice}
|
||||||
isLoading={loadingCheckout || loadingSlots}
|
period={t`one-time purchase`}
|
||||||
/>
|
isPopular
|
||||||
<PricingCard
|
onSelect={() => handleSelectPlan('gift1Month')}
|
||||||
title={t`Visionary Gift`}
|
buttonText={t`Buy Gift`}
|
||||||
price={visionaryPrice}
|
isLoading={loadingCheckout || loadingSlots}
|
||||||
period={t`one-time, lifetime`}
|
disabled={purchaseDisabled}
|
||||||
remainingSlots={loadingSlots ? undefined : visionarySlots?.remaining}
|
/>
|
||||||
onSelect={() => handleSelectPlan('giftVisionary')}
|
</PurchaseDisabledWrapper>
|
||||||
buttonText={t`Buy Gift`}
|
<PurchaseDisabledWrapper disabled={purchaseDisabled || isVisionarySoldOut} tooltipText={tooltipText}>
|
||||||
isLoading={loadingCheckout || loadingSlots}
|
<PricingCard
|
||||||
disabled={isVisionarySoldOut}
|
title={t`Visionary Gift`}
|
||||||
soldOut={isVisionarySoldOut}
|
price={visionaryPrice}
|
||||||
/>
|
period={t`one-time, lifetime`}
|
||||||
|
remainingSlots={loadingSlots ? undefined : visionarySlots?.remaining}
|
||||||
|
onSelect={() => handleSelectPlan('giftVisionary')}
|
||||||
|
buttonText={t`Buy Gift`}
|
||||||
|
isLoading={loadingCheckout || loadingSlots}
|
||||||
|
disabled={purchaseDisabled || isVisionarySoldOut}
|
||||||
|
soldOut={isVisionarySoldOut}
|
||||||
|
/>
|
||||||
|
</PurchaseDisabledWrapper>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.footerContainer}>
|
<div className={styles.footerContainer}>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import gridStyles from '../PricingGrid.module.css';
|
|||||||
import {PurchaseDisclaimer} from '../PurchaseDisclaimer';
|
import {PurchaseDisclaimer} from '../PurchaseDisclaimer';
|
||||||
import {ToggleButton} from '../ToggleButton';
|
import {ToggleButton} from '../ToggleButton';
|
||||||
import styles from './PricingSection.module.css';
|
import styles from './PricingSection.module.css';
|
||||||
|
import {PurchaseDisabledWrapper} from './PurchaseDisabledWrapper';
|
||||||
|
|
||||||
interface PricingSectionProps {
|
interface PricingSectionProps {
|
||||||
isGiftMode: boolean;
|
isGiftMode: boolean;
|
||||||
@@ -39,6 +40,8 @@ interface PricingSectionProps {
|
|||||||
loadingSlots: boolean;
|
loadingSlots: boolean;
|
||||||
isVisionarySoldOut: boolean;
|
isVisionarySoldOut: boolean;
|
||||||
handleSelectPlan: (plan: 'monthly' | 'yearly' | 'visionary' | 'gift1Month' | 'gift1Year' | 'giftVisionary') => void;
|
handleSelectPlan: (plan: 'monthly' | 'yearly' | 'visionary' | 'gift1Month' | 'gift1Year' | 'giftVisionary') => void;
|
||||||
|
purchaseDisabled?: boolean;
|
||||||
|
purchaseDisabledTooltip?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PricingSection: React.FC<PricingSectionProps> = observer(
|
export const PricingSection: React.FC<PricingSectionProps> = observer(
|
||||||
@@ -53,8 +56,11 @@ export const PricingSection: React.FC<PricingSectionProps> = observer(
|
|||||||
loadingSlots,
|
loadingSlots,
|
||||||
isVisionarySoldOut,
|
isVisionarySoldOut,
|
||||||
handleSelectPlan,
|
handleSelectPlan,
|
||||||
|
purchaseDisabled = false,
|
||||||
|
purchaseDisabledTooltip,
|
||||||
}) => {
|
}) => {
|
||||||
const {t} = useLingui();
|
const {t} = useLingui();
|
||||||
|
const tooltipText: React.ReactNode = purchaseDisabledTooltip ?? t`Claim your account to purchase Fluxer Plutonium.`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={styles.section}>
|
<section className={styles.section}>
|
||||||
@@ -67,65 +73,81 @@ export const PricingSection: React.FC<PricingSectionProps> = observer(
|
|||||||
<div className={gridStyles.gridThreeColumns}>
|
<div className={gridStyles.gridThreeColumns}>
|
||||||
{!isGiftMode ? (
|
{!isGiftMode ? (
|
||||||
<>
|
<>
|
||||||
<PricingCard
|
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
|
||||||
title={t`Monthly`}
|
<PricingCard
|
||||||
price={monthlyPrice}
|
title={t`Monthly`}
|
||||||
period={t`per month`}
|
price={monthlyPrice}
|
||||||
onSelect={() => handleSelectPlan('monthly')}
|
period={t`per month`}
|
||||||
isLoading={loadingCheckout || loadingSlots}
|
onSelect={() => handleSelectPlan('monthly')}
|
||||||
/>
|
isLoading={loadingCheckout || loadingSlots}
|
||||||
<PricingCard
|
disabled={purchaseDisabled}
|
||||||
title={t`Yearly`}
|
/>
|
||||||
price={yearlyPrice}
|
</PurchaseDisabledWrapper>
|
||||||
period={t`per year`}
|
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
|
||||||
badge={t`Save 17%`}
|
<PricingCard
|
||||||
isPopular
|
title={t`Yearly`}
|
||||||
onSelect={() => handleSelectPlan('yearly')}
|
price={yearlyPrice}
|
||||||
buttonText={t`Upgrade Now`}
|
period={t`per year`}
|
||||||
isLoading={loadingCheckout || loadingSlots}
|
badge={t`Save 17%`}
|
||||||
/>
|
isPopular
|
||||||
<PricingCard
|
onSelect={() => handleSelectPlan('yearly')}
|
||||||
title={t`Visionary`}
|
buttonText={t`Upgrade Now`}
|
||||||
price={visionaryPrice}
|
isLoading={loadingCheckout || loadingSlots}
|
||||||
period={t`one-time, lifetime`}
|
disabled={purchaseDisabled}
|
||||||
remainingSlots={loadingSlots ? undefined : visionarySlots?.remaining}
|
/>
|
||||||
onSelect={() => handleSelectPlan('visionary')}
|
</PurchaseDisabledWrapper>
|
||||||
isLoading={loadingCheckout || loadingSlots}
|
<PurchaseDisabledWrapper disabled={purchaseDisabled || isVisionarySoldOut} tooltipText={tooltipText}>
|
||||||
disabled={isVisionarySoldOut}
|
<PricingCard
|
||||||
soldOut={isVisionarySoldOut}
|
title={t`Visionary`}
|
||||||
/>
|
price={visionaryPrice}
|
||||||
|
period={t`one-time, lifetime`}
|
||||||
|
remainingSlots={loadingSlots ? undefined : visionarySlots?.remaining}
|
||||||
|
onSelect={() => handleSelectPlan('visionary')}
|
||||||
|
isLoading={loadingCheckout || loadingSlots}
|
||||||
|
disabled={purchaseDisabled || isVisionarySoldOut}
|
||||||
|
soldOut={isVisionarySoldOut}
|
||||||
|
/>
|
||||||
|
</PurchaseDisabledWrapper>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<PricingCard
|
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
|
||||||
title={t`1 Year Gift`}
|
<PricingCard
|
||||||
price={yearlyPrice}
|
title={t`1 Year Gift`}
|
||||||
period={t`one-time purchase`}
|
price={yearlyPrice}
|
||||||
badge={t`Save 17%`}
|
period={t`one-time purchase`}
|
||||||
onSelect={() => handleSelectPlan('gift1Year')}
|
badge={t`Save 17%`}
|
||||||
buttonText={t`Buy Gift`}
|
onSelect={() => handleSelectPlan('gift1Year')}
|
||||||
isLoading={loadingCheckout || loadingSlots}
|
buttonText={t`Buy Gift`}
|
||||||
/>
|
isLoading={loadingCheckout || loadingSlots}
|
||||||
<PricingCard
|
disabled={purchaseDisabled}
|
||||||
title={t`1 Month Gift`}
|
/>
|
||||||
price={monthlyPrice}
|
</PurchaseDisabledWrapper>
|
||||||
period={t`one-time purchase`}
|
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
|
||||||
isPopular
|
<PricingCard
|
||||||
onSelect={() => handleSelectPlan('gift1Month')}
|
title={t`1 Month Gift`}
|
||||||
buttonText={t`Buy Gift`}
|
price={monthlyPrice}
|
||||||
isLoading={loadingCheckout || loadingSlots}
|
period={t`one-time purchase`}
|
||||||
/>
|
isPopular
|
||||||
<PricingCard
|
onSelect={() => handleSelectPlan('gift1Month')}
|
||||||
title={t`Visionary Gift`}
|
buttonText={t`Buy Gift`}
|
||||||
price={visionaryPrice}
|
isLoading={loadingCheckout || loadingSlots}
|
||||||
period={t`one-time, lifetime`}
|
disabled={purchaseDisabled}
|
||||||
remainingSlots={loadingSlots ? undefined : visionarySlots?.remaining}
|
/>
|
||||||
onSelect={() => handleSelectPlan('giftVisionary')}
|
</PurchaseDisabledWrapper>
|
||||||
buttonText={t`Buy Gift`}
|
<PurchaseDisabledWrapper disabled={purchaseDisabled || isVisionarySoldOut} tooltipText={tooltipText}>
|
||||||
isLoading={loadingCheckout || loadingSlots}
|
<PricingCard
|
||||||
disabled={isVisionarySoldOut}
|
title={t`Visionary Gift`}
|
||||||
soldOut={isVisionarySoldOut}
|
price={visionaryPrice}
|
||||||
/>
|
period={t`one-time, lifetime`}
|
||||||
|
remainingSlots={loadingSlots ? undefined : visionarySlots?.remaining}
|
||||||
|
onSelect={() => handleSelectPlan('giftVisionary')}
|
||||||
|
buttonText={t`Buy Gift`}
|
||||||
|
isLoading={loadingCheckout || loadingSlots}
|
||||||
|
disabled={purchaseDisabled || isVisionarySoldOut}
|
||||||
|
soldOut={isVisionarySoldOut}
|
||||||
|
/>
|
||||||
|
</PurchaseDisabledWrapper>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Fluxer Contributors
|
||||||
|
*
|
||||||
|
* This file is part of Fluxer.
|
||||||
|
*
|
||||||
|
* Fluxer is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Fluxer 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 Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type React from 'react';
|
||||||
|
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||||
|
|
||||||
|
interface PurchaseDisabledWrapperProps {
|
||||||
|
disabled: boolean;
|
||||||
|
tooltipText: React.ReactNode;
|
||||||
|
children: React.ReactElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PurchaseDisabledWrapper: React.FC<PurchaseDisabledWrapperProps> = ({disabled, tooltipText, children}) => {
|
||||||
|
if (!disabled) return children;
|
||||||
|
|
||||||
|
const tooltipContent = typeof tooltipText === 'function' ? (tooltipText as () => React.ReactNode) : () => tooltipText;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip text={tooltipContent}>
|
||||||
|
<div>{children}</div>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -17,12 +17,13 @@
|
|||||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {Trans} from '@lingui/react/macro';
|
import {Trans, useLingui} from '@lingui/react/macro';
|
||||||
import {DotsThreeIcon} from '@phosphor-icons/react';
|
import {DotsThreeIcon} from '@phosphor-icons/react';
|
||||||
import {clsx} from 'clsx';
|
import {clsx} from 'clsx';
|
||||||
import {observer} from 'mobx-react-lite';
|
import {observer} from 'mobx-react-lite';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import {Button} from '~/components/uikit/Button/Button';
|
import {Button} from '~/components/uikit/Button/Button';
|
||||||
|
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||||
import type {UserRecord} from '~/records/UserRecord';
|
import type {UserRecord} from '~/records/UserRecord';
|
||||||
import {PerksButton} from '../PerksButton';
|
import {PerksButton} from '../PerksButton';
|
||||||
import type {GracePeriodInfo} from './hooks/useSubscriptionStatus';
|
import type {GracePeriodInfo} from './hooks/useSubscriptionStatus';
|
||||||
@@ -61,6 +62,8 @@ interface SubscriptionCardProps {
|
|||||||
handleCancelSubscription: () => void;
|
handleCancelSubscription: () => void;
|
||||||
handleCommunityButtonPointerDown: (event: React.PointerEvent) => void;
|
handleCommunityButtonPointerDown: (event: React.PointerEvent) => void;
|
||||||
handleCommunityButtonClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
handleCommunityButtonClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
|
purchaseDisabled?: boolean;
|
||||||
|
purchaseDisabledTooltip?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
|
export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
|
||||||
@@ -97,9 +100,25 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
|
|||||||
handleCancelSubscription,
|
handleCancelSubscription,
|
||||||
handleCommunityButtonPointerDown,
|
handleCommunityButtonPointerDown,
|
||||||
handleCommunityButtonClick,
|
handleCommunityButtonClick,
|
||||||
|
purchaseDisabled = false,
|
||||||
|
purchaseDisabledTooltip,
|
||||||
}) => {
|
}) => {
|
||||||
|
const {t} = useLingui();
|
||||||
const {isInGracePeriod, isExpired: isFullyExpired, graceEndDate} = gracePeriodInfo;
|
const {isInGracePeriod, isExpired: isFullyExpired, graceEndDate} = gracePeriodInfo;
|
||||||
const isPremium = currentUser.isPremium();
|
const isPremium = currentUser.isPremium();
|
||||||
|
const tooltipText: string | (() => React.ReactNode) =
|
||||||
|
purchaseDisabledTooltip != null
|
||||||
|
? () => purchaseDisabledTooltip
|
||||||
|
: t`Claim your account to purchase or redeem Fluxer Plutonium.`;
|
||||||
|
|
||||||
|
const wrapIfDisabled = (element: React.ReactElement, key: string, disabled: boolean) =>
|
||||||
|
disabled ? (
|
||||||
|
<Tooltip key={key} text={tooltipText}>
|
||||||
|
<div>{element}</div>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
element
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx(styles.card, subscriptionCardColorClass)}>
|
<div className={clsx(styles.card, subscriptionCardColorClass)}>
|
||||||
@@ -251,44 +270,63 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
|
|||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
{isGiftSubscription ? (
|
{isGiftSubscription ? (
|
||||||
<>
|
<>
|
||||||
<Button variant="inverted" onClick={navigateToRedeemGift} small className={styles.actionButton}>
|
{wrapIfDisabled(
|
||||||
<Trans>Redeem Gift Code</Trans>
|
|
||||||
</Button>
|
|
||||||
{!isVisionary && !isVisionarySoldOut && (
|
|
||||||
<Button
|
<Button
|
||||||
variant="inverted"
|
variant="inverted"
|
||||||
onClick={() => handleSelectPlan('visionary')}
|
onClick={navigateToRedeemGift}
|
||||||
submitting={loadingCheckout || loadingSlots}
|
|
||||||
small
|
small
|
||||||
className={styles.actionButton}
|
className={styles.actionButton}
|
||||||
|
disabled={purchaseDisabled}
|
||||||
>
|
>
|
||||||
<Trans>Upgrade to Visionary</Trans>
|
<Trans>Redeem Gift Code</Trans>
|
||||||
</Button>
|
</Button>,
|
||||||
|
'redeem-gift',
|
||||||
|
purchaseDisabled,
|
||||||
)}
|
)}
|
||||||
|
{!isVisionary &&
|
||||||
|
!isVisionarySoldOut &&
|
||||||
|
wrapIfDisabled(
|
||||||
|
<Button
|
||||||
|
variant="inverted"
|
||||||
|
onClick={() => handleSelectPlan('visionary')}
|
||||||
|
submitting={loadingCheckout || loadingSlots}
|
||||||
|
small
|
||||||
|
className={styles.actionButton}
|
||||||
|
disabled={purchaseDisabled}
|
||||||
|
>
|
||||||
|
<Trans>Upgrade to Visionary</Trans>
|
||||||
|
</Button>,
|
||||||
|
'upgrade-gift-visionary',
|
||||||
|
purchaseDisabled,
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{hasEverPurchased && (
|
{hasEverPurchased &&
|
||||||
<Button
|
wrapIfDisabled(
|
||||||
variant="inverted"
|
<Button
|
||||||
onClick={shouldUseReactivateQuickAction ? handleReactivateSubscription : handleOpenCustomerPortal}
|
variant="inverted"
|
||||||
submitting={shouldUseReactivateQuickAction ? loadingReactivate : loadingPortal}
|
onClick={shouldUseReactivateQuickAction ? handleReactivateSubscription : handleOpenCustomerPortal}
|
||||||
small
|
submitting={shouldUseReactivateQuickAction ? loadingReactivate : loadingPortal}
|
||||||
className={styles.actionButton}
|
small
|
||||||
>
|
className={styles.actionButton}
|
||||||
{isFullyExpired ? (
|
disabled={purchaseDisabled && shouldUseReactivateQuickAction}
|
||||||
<Trans>Resubscribe</Trans>
|
>
|
||||||
) : isInGracePeriod ? (
|
{isFullyExpired ? (
|
||||||
<Trans>Resubscribe</Trans>
|
<Trans>Resubscribe</Trans>
|
||||||
) : premiumWillCancel ? (
|
) : isInGracePeriod ? (
|
||||||
<Trans>Reactivate</Trans>
|
<Trans>Resubscribe</Trans>
|
||||||
) : isVisionary ? (
|
) : premiumWillCancel ? (
|
||||||
<Trans>Open Customer Portal</Trans>
|
<Trans>Reactivate</Trans>
|
||||||
) : (
|
) : isVisionary ? (
|
||||||
<Trans>Manage Subscription</Trans>
|
<Trans>Open Customer Portal</Trans>
|
||||||
)}
|
) : (
|
||||||
</Button>
|
<Trans>Manage Subscription</Trans>
|
||||||
)}
|
)}
|
||||||
|
</Button>,
|
||||||
|
'manage-reactivate',
|
||||||
|
purchaseDisabled && shouldUseReactivateQuickAction,
|
||||||
|
)}
|
||||||
|
|
||||||
{isVisionary && (
|
{isVisionary && (
|
||||||
<Button
|
<Button
|
||||||
@@ -305,17 +343,22 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isVisionary && !isVisionarySoldOut && (
|
{!isVisionary &&
|
||||||
<Button
|
!isVisionarySoldOut &&
|
||||||
variant="inverted"
|
wrapIfDisabled(
|
||||||
onClick={() => handleSelectPlan('visionary')}
|
<Button
|
||||||
submitting={loadingCheckout || loadingSlots}
|
variant="inverted"
|
||||||
small
|
onClick={() => handleSelectPlan('visionary')}
|
||||||
className={styles.actionButton}
|
submitting={loadingCheckout || loadingSlots}
|
||||||
>
|
small
|
||||||
<Trans>Upgrade to Visionary</Trans>
|
className={styles.actionButton}
|
||||||
</Button>
|
disabled={purchaseDisabled}
|
||||||
)}
|
>
|
||||||
|
<Trans>Upgrade to Visionary</Trans>
|
||||||
|
</Button>,
|
||||||
|
'upgrade-visionary',
|
||||||
|
purchaseDisabled,
|
||||||
|
)}
|
||||||
|
|
||||||
{shouldUseCancelQuickAction && (
|
{shouldUseCancelQuickAction && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {observer} from 'mobx-react-lite';
|
|||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import type {VisionarySlots} from '~/actions/PremiumActionCreators';
|
import type {VisionarySlots} from '~/actions/PremiumActionCreators';
|
||||||
import {Button} from '~/components/uikit/Button/Button';
|
import {Button} from '~/components/uikit/Button/Button';
|
||||||
|
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||||
import {VisionaryBenefit} from '../VisionaryBenefit';
|
import {VisionaryBenefit} from '../VisionaryBenefit';
|
||||||
import {SectionHeader} from './SectionHeader';
|
import {SectionHeader} from './SectionHeader';
|
||||||
import styles from './VisionarySection.module.css';
|
import styles from './VisionarySection.module.css';
|
||||||
@@ -36,6 +37,8 @@ interface VisionarySectionProps {
|
|||||||
loadingCheckout: boolean;
|
loadingCheckout: boolean;
|
||||||
loadingSlots: boolean;
|
loadingSlots: boolean;
|
||||||
handleSelectPlan: (plan: 'visionary') => void;
|
handleSelectPlan: (plan: 'visionary') => void;
|
||||||
|
purchaseDisabled?: boolean;
|
||||||
|
purchaseDisabledTooltip?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VisionarySection: React.FC<VisionarySectionProps> = observer(
|
export const VisionarySection: React.FC<VisionarySectionProps> = observer(
|
||||||
@@ -48,9 +51,15 @@ export const VisionarySection: React.FC<VisionarySectionProps> = observer(
|
|||||||
loadingCheckout,
|
loadingCheckout,
|
||||||
loadingSlots,
|
loadingSlots,
|
||||||
handleSelectPlan,
|
handleSelectPlan,
|
||||||
|
purchaseDisabled = false,
|
||||||
|
purchaseDisabledTooltip,
|
||||||
}) => {
|
}) => {
|
||||||
const {t} = useLingui();
|
const {t} = useLingui();
|
||||||
const currentAccessLabel = isGiftSubscription ? t`gift time` : t`subscription`;
|
const currentAccessLabel = isGiftSubscription ? t`gift time` : t`subscription`;
|
||||||
|
const tooltipText: string | (() => React.ReactNode) =
|
||||||
|
purchaseDisabledTooltip != null
|
||||||
|
? () => purchaseDisabledTooltip
|
||||||
|
: t`Claim your account to purchase Fluxer Plutonium.`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={styles.section}>
|
<section className={styles.section}>
|
||||||
@@ -99,15 +108,32 @@ export const VisionarySection: React.FC<VisionarySectionProps> = observer(
|
|||||||
|
|
||||||
{!isVisionary && visionarySlots && visionarySlots.remaining > 0 && (
|
{!isVisionary && visionarySlots && visionarySlots.remaining > 0 && (
|
||||||
<div className={styles.ctaContainer}>
|
<div className={styles.ctaContainer}>
|
||||||
<Button
|
{purchaseDisabled ? (
|
||||||
variant="primary"
|
<Tooltip text={tooltipText}>
|
||||||
onClick={() => handleSelectPlan('visionary')}
|
<div>
|
||||||
submitting={loadingCheckout || loadingSlots}
|
<Button
|
||||||
className={styles.ctaButton}
|
variant="primary"
|
||||||
>
|
onClick={() => handleSelectPlan('visionary')}
|
||||||
<CrownIcon className={styles.ctaIcon} weight="fill" />
|
submitting={loadingCheckout || loadingSlots}
|
||||||
<Trans>Upgrade to Visionary — {formatter.format(visionarySlots.remaining)} Left</Trans>
|
className={styles.ctaButton}
|
||||||
</Button>
|
disabled
|
||||||
|
>
|
||||||
|
<CrownIcon className={styles.ctaIcon} weight="fill" />
|
||||||
|
<Trans>Upgrade to Visionary — {formatter.format(visionarySlots.remaining)} Left</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => handleSelectPlan('visionary')}
|
||||||
|
submitting={loadingCheckout || loadingSlots}
|
||||||
|
className={styles.ctaButton}
|
||||||
|
>
|
||||||
|
<CrownIcon className={styles.ctaIcon} weight="fill" />
|
||||||
|
<Trans>Upgrade to Visionary — {formatter.format(visionarySlots.remaining)} Left</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{isPremium && (
|
{isPremium && (
|
||||||
<p className={styles.disclaimer}>
|
<p className={styles.disclaimer}>
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import GuildStore from '~/stores/GuildStore';
|
|||||||
import PermissionStore from '~/stores/PermissionStore';
|
import PermissionStore from '~/stores/PermissionStore';
|
||||||
import RelationshipStore from '~/stores/RelationshipStore';
|
import RelationshipStore from '~/stores/RelationshipStore';
|
||||||
import UserProfileMobileStore from '~/stores/UserProfileMobileStore';
|
import UserProfileMobileStore from '~/stores/UserProfileMobileStore';
|
||||||
|
import UserStore from '~/stores/UserStore';
|
||||||
import * as CallUtils from '~/utils/CallUtils';
|
import * as CallUtils from '~/utils/CallUtils';
|
||||||
import * as NicknameUtils from '~/utils/NicknameUtils';
|
import * as NicknameUtils from '~/utils/NicknameUtils';
|
||||||
import * as PermissionUtils from '~/utils/PermissionUtils';
|
import * as PermissionUtils from '~/utils/PermissionUtils';
|
||||||
@@ -74,6 +75,7 @@ export const GuildMemberActionsSheet: FC<GuildMemberActionsSheetProps> = observe
|
|||||||
const currentUserId = AuthenticationStore.currentUserId;
|
const currentUserId = AuthenticationStore.currentUserId;
|
||||||
const isCurrentUser = user.id === currentUserId;
|
const isCurrentUser = user.id === currentUserId;
|
||||||
const isBot = user.bot;
|
const isBot = user.bot;
|
||||||
|
const currentUserUnclaimed = !(UserStore.currentUser?.isClaimed() ?? true);
|
||||||
|
|
||||||
const relationship = RelationshipStore.getRelationship(user.id);
|
const relationship = RelationshipStore.getRelationship(user.id);
|
||||||
const relationshipType = relationship?.type;
|
const relationshipType = relationship?.type;
|
||||||
@@ -262,7 +264,8 @@ export const GuildMemberActionsSheet: FC<GuildMemberActionsSheetProps> = observe
|
|||||||
});
|
});
|
||||||
} else if (
|
} else if (
|
||||||
relationshipType !== RelationshipTypes.OUTGOING_REQUEST &&
|
relationshipType !== RelationshipTypes.OUTGOING_REQUEST &&
|
||||||
relationshipType !== RelationshipTypes.BLOCKED
|
relationshipType !== RelationshipTypes.BLOCKED &&
|
||||||
|
!currentUserUnclaimed
|
||||||
) {
|
) {
|
||||||
relationshipItems.push({
|
relationshipItems.push({
|
||||||
icon: <UserPlusIcon className={styles.icon} />,
|
icon: <UserPlusIcon className={styles.icon} />,
|
||||||
|
|||||||
@@ -36,13 +36,16 @@ interface StatusSlateProps {
|
|||||||
description: React.ReactNode;
|
description: React.ReactNode;
|
||||||
actions?: Array<StatusAction>;
|
actions?: Array<StatusAction>;
|
||||||
fullHeight?: boolean;
|
fullHeight?: boolean;
|
||||||
|
iconClassName?: string;
|
||||||
|
iconStyle?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const StatusSlate: React.FC<StatusSlateProps> = observer(
|
export const StatusSlate: React.FC<StatusSlateProps> = observer(
|
||||||
({Icon, title, description, actions = [], fullHeight = false}) => {
|
({Icon, title, description, actions = [], fullHeight = false, iconClassName, iconStyle}) => {
|
||||||
|
const iconClass = [styles.icon, iconClassName].filter(Boolean).join(' ');
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.container} ${fullHeight ? styles.fullHeight : ''}`}>
|
<div className={`${styles.container} ${fullHeight ? styles.fullHeight : ''}`}>
|
||||||
<Icon className={styles.icon} aria-hidden />
|
<Icon className={iconClass} style={iconStyle} aria-hidden />
|
||||||
<h3 className={styles.title}>{title}</h3>
|
<h3 className={styles.title}>{title}</h3>
|
||||||
<p className={styles.description}>{description}</p>
|
<p className={styles.description}>{description}</p>
|
||||||
{actions.length > 0 && (
|
{actions.length > 0 && (
|
||||||
|
|||||||
@@ -100,3 +100,7 @@
|
|||||||
border-top: 1px solid var(--background-header-secondary);
|
border-top: 1px solid var(--background-header-secondary);
|
||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.claimButton {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {observer} from 'mobx-react-lite';
|
|||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||||
import {modal} from '~/actions/ModalActionCreators';
|
import {modal} from '~/actions/ModalActionCreators';
|
||||||
import {ClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
||||||
import {EmailChangeModal} from '~/components/modals/EmailChangeModal';
|
import {EmailChangeModal} from '~/components/modals/EmailChangeModal';
|
||||||
import {PasswordChangeModal} from '~/components/modals/PasswordChangeModal';
|
import {PasswordChangeModal} from '~/components/modals/PasswordChangeModal';
|
||||||
import {SettingsTabSection} from '~/components/modals/shared/SettingsTabLayout';
|
import {SettingsTabSection} from '~/components/modals/shared/SettingsTabLayout';
|
||||||
@@ -94,7 +94,7 @@ export const AccountTabContent: React.FC<AccountTabProps> = observer(
|
|||||||
<Trans>No email address set</Trans>
|
<Trans>No email address set</Trans>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button small={true} onClick={() => ModalActionCreators.push(modal(() => <ClaimAccountModal />))}>
|
<Button small={true} className={styles.claimButton} fitContent onClick={() => openClaimAccountModal()}>
|
||||||
<Trans>Add Email</Trans>
|
<Trans>Add Email</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -134,7 +134,7 @@ export const AccountTabContent: React.FC<AccountTabProps> = observer(
|
|||||||
<Trans>No password set</Trans>
|
<Trans>No password set</Trans>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button small={true} onClick={() => ModalActionCreators.push(modal(() => <ClaimAccountModal />))}>
|
<Button small={true} className={styles.claimButton} fitContent onClick={() => openClaimAccountModal()}>
|
||||||
<Trans>Set Password</Trans>
|
<Trans>Set Password</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -96,3 +96,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.claimButton {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
|||||||
import {modal} from '~/actions/ModalActionCreators';
|
import {modal} from '~/actions/ModalActionCreators';
|
||||||
import * as UserActionCreators from '~/actions/UserActionCreators';
|
import * as UserActionCreators from '~/actions/UserActionCreators';
|
||||||
import {BackupCodesViewModal} from '~/components/modals/BackupCodesViewModal';
|
import {BackupCodesViewModal} from '~/components/modals/BackupCodesViewModal';
|
||||||
import {ClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
||||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||||
import {MfaTotpDisableModal} from '~/components/modals/MfaTotpDisableModal';
|
import {MfaTotpDisableModal} from '~/components/modals/MfaTotpDisableModal';
|
||||||
import {MfaTotpEnableModal} from '~/components/modals/MfaTotpEnableModal';
|
import {MfaTotpEnableModal} from '~/components/modals/MfaTotpEnableModal';
|
||||||
@@ -202,7 +202,7 @@ export const SecurityTabContent: React.FC<SecurityTabProps> = observer(
|
|||||||
<Trans>Claim your account to access security features like two-factor authentication and passkeys.</Trans>
|
<Trans>Claim your account to access security features like two-factor authentication and passkeys.</Trans>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Button onClick={() => ModalActionCreators.push(modal(() => <ClaimAccountModal />))}>
|
<Button className={styles.claimButton} fitContent onClick={() => openClaimAccountModal()}>
|
||||||
<Trans>Claim Account</Trans>
|
<Trans>Claim Account</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</SettingsTabSection>
|
</SettingsTabSection>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {Trans} from '@lingui/react/macro';
|
import {Trans, useLingui} from '@lingui/react/macro';
|
||||||
import {BookOpenIcon, WarningCircleIcon} from '@phosphor-icons/react';
|
import {BookOpenIcon, WarningCircleIcon} from '@phosphor-icons/react';
|
||||||
import {observer} from 'mobx-react-lite';
|
import {observer} from 'mobx-react-lite';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -38,12 +38,16 @@ import styles from '~/components/modals/tabs/ApplicationsTab/ApplicationsTab.mod
|
|||||||
import ApplicationsTabStore from '~/components/modals/tabs/ApplicationsTab/ApplicationsTabStore';
|
import ApplicationsTabStore from '~/components/modals/tabs/ApplicationsTab/ApplicationsTabStore';
|
||||||
import {Button} from '~/components/uikit/Button/Button';
|
import {Button} from '~/components/uikit/Button/Button';
|
||||||
import {Spinner} from '~/components/uikit/Spinner';
|
import {Spinner} from '~/components/uikit/Spinner';
|
||||||
|
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||||
import type {DeveloperApplication} from '~/records/DeveloperApplicationRecord';
|
import type {DeveloperApplication} from '~/records/DeveloperApplicationRecord';
|
||||||
|
import UserStore from '~/stores/UserStore';
|
||||||
|
|
||||||
const ApplicationsTab: React.FC = observer(() => {
|
const ApplicationsTab: React.FC = observer(() => {
|
||||||
|
const {t} = useLingui();
|
||||||
const {checkUnsavedChanges} = useUnsavedChangesFlash('applications');
|
const {checkUnsavedChanges} = useUnsavedChangesFlash('applications');
|
||||||
const {setContentKey} = useSettingsContentKey();
|
const {setContentKey} = useSettingsContentKey();
|
||||||
const store = ApplicationsTabStore;
|
const store = ApplicationsTabStore;
|
||||||
|
const isUnclaimed = !(UserStore.currentUser?.isClaimed() ?? false);
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
setContentKey(store.contentKey);
|
setContentKey(store.contentKey);
|
||||||
@@ -138,9 +142,19 @@ const ApplicationsTab: React.FC = observer(() => {
|
|||||||
description={<Trans>Create and manage applications and bots for your account.</Trans>}
|
description={<Trans>Create and manage applications and bots for your account.</Trans>}
|
||||||
>
|
>
|
||||||
<div className={styles.buttonContainer}>
|
<div className={styles.buttonContainer}>
|
||||||
<Button variant="primary" fitContainer={false} fitContent onClick={openCreateModal}>
|
{isUnclaimed ? (
|
||||||
<Trans>Create Application</Trans>
|
<Tooltip text={t`Claim your account to create applications.`}>
|
||||||
</Button>
|
<div>
|
||||||
|
<Button variant="primary" fitContainer={false} fitContent onClick={openCreateModal} disabled>
|
||||||
|
<Trans>Create Application</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Button variant="primary" fitContainer={false} fitContent onClick={openCreateModal}>
|
||||||
|
<Trans>Create Application</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<a className={styles.documentationLink} href="https://fluxer.dev" target="_blank" rel="noreferrer">
|
<a className={styles.documentationLink} href="https://fluxer.dev" target="_blank" rel="noreferrer">
|
||||||
<BookOpenIcon weight="fill" size={18} className={styles.documentationIcon} />
|
<BookOpenIcon weight="fill" size={18} className={styles.documentationIcon} />
|
||||||
<Trans>Read the Documentation (fluxer.dev)</Trans>
|
<Trans>Read the Documentation (fluxer.dev)</Trans>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {observer} from 'mobx-react-lite';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import * as BetaCodeActionCreators from '~/actions/BetaCodeActionCreators';
|
import * as BetaCodeActionCreators from '~/actions/BetaCodeActionCreators';
|
||||||
import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators';
|
import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators';
|
||||||
|
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
||||||
import {
|
import {
|
||||||
SettingsTabContainer,
|
SettingsTabContainer,
|
||||||
SettingsTabContent,
|
SettingsTabContent,
|
||||||
@@ -39,6 +40,7 @@ import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
|||||||
import type {BetaCodeRecord} from '~/records/BetaCodeRecord';
|
import type {BetaCodeRecord} from '~/records/BetaCodeRecord';
|
||||||
import BetaCodeStore from '~/stores/BetaCodeStore';
|
import BetaCodeStore from '~/stores/BetaCodeStore';
|
||||||
import MobileLayoutStore from '~/stores/MobileLayoutStore';
|
import MobileLayoutStore from '~/stores/MobileLayoutStore';
|
||||||
|
import UserStore from '~/stores/UserStore';
|
||||||
import * as DateUtils from '~/utils/DateUtils';
|
import * as DateUtils from '~/utils/DateUtils';
|
||||||
import styles from './BetaCodesTab.module.css';
|
import styles from './BetaCodesTab.module.css';
|
||||||
|
|
||||||
@@ -247,10 +249,12 @@ const BetaCodesTab: React.FC = observer(() => {
|
|||||||
const fetchStatus = BetaCodeStore.fetchStatus;
|
const fetchStatus = BetaCodeStore.fetchStatus;
|
||||||
const allowance = BetaCodeStore.allowance;
|
const allowance = BetaCodeStore.allowance;
|
||||||
const nextResetAt = BetaCodeStore.nextResetAt;
|
const nextResetAt = BetaCodeStore.nextResetAt;
|
||||||
|
const isClaimed = UserStore.currentUser?.isClaimed() ?? false;
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
if (!isClaimed) return;
|
||||||
BetaCodeActionCreators.fetch();
|
BetaCodeActionCreators.fetch();
|
||||||
}, []);
|
}, [isClaimed]);
|
||||||
|
|
||||||
const sortedBetaCodes = React.useMemo(() => {
|
const sortedBetaCodes = React.useMemo(() => {
|
||||||
return [...betaCodes].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
return [...betaCodes].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||||
@@ -280,6 +284,27 @@ const BetaCodesTab: React.FC = observer(() => {
|
|||||||
return i18n._(msg`${allowance} codes remaining this week`);
|
return i18n._(msg`${allowance} codes remaining this week`);
|
||||||
}, [allowance, nextResetAt, i18n]);
|
}, [allowance, nextResetAt, i18n]);
|
||||||
|
|
||||||
|
if (!isClaimed) {
|
||||||
|
return (
|
||||||
|
<SettingsTabContainer>
|
||||||
|
<SettingsTabContent>
|
||||||
|
<StatusSlate
|
||||||
|
Icon={TicketIcon}
|
||||||
|
title={<Trans>Claim your account</Trans>}
|
||||||
|
description={<Trans>Claim your account to generate beta codes.</Trans>}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
text: <Trans>Claim Account</Trans>,
|
||||||
|
onClick: () => openClaimAccountModal({force: true}),
|
||||||
|
variant: 'primary',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SettingsTabContent>
|
||||||
|
</SettingsTabContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (fetchStatus === 'pending' || fetchStatus === 'idle') {
|
if (fetchStatus === 'pending' || fetchStatus === 'idle') {
|
||||||
return (
|
return (
|
||||||
<div className={styles.spinnerContainer}>
|
<div className={styles.spinnerContainer}>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {useCallback, useState} from 'react';
|
|||||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||||
import {testBulkDeleteAllMessages} from '~/actions/UserActionCreators';
|
import {testBulkDeleteAllMessages} from '~/actions/UserActionCreators';
|
||||||
import {CaptchaModal} from '~/components/modals/CaptchaModal';
|
import {CaptchaModal} from '~/components/modals/CaptchaModal';
|
||||||
import {ClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
||||||
import {KeyboardModeIntroModal} from '~/components/modals/KeyboardModeIntroModal';
|
import {KeyboardModeIntroModal} from '~/components/modals/KeyboardModeIntroModal';
|
||||||
import {Button} from '~/components/uikit/Button/Button';
|
import {Button} from '~/components/uikit/Button/Button';
|
||||||
import type {GatewaySocket} from '~/lib/GatewaySocket';
|
import type {GatewaySocket} from '~/lib/GatewaySocket';
|
||||||
@@ -67,7 +67,7 @@ export const ToolsTabContent: React.FC<ToolsTabContentProps> = observer(({socket
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleOpenClaimAccountModal = useCallback(() => {
|
const handleOpenClaimAccountModal = useCallback(() => {
|
||||||
ModalActionCreators.push(ModalActionCreators.modal(() => <ClaimAccountModal />));
|
openClaimAccountModal({force: true});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (shouldCrash) {
|
if (shouldCrash) {
|
||||||
|
|||||||
@@ -58,6 +58,7 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
box-sizing: border-box;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
border: 1px solid var(--background-header-secondary);
|
border: 1px solid var(--background-header-secondary);
|
||||||
@@ -117,6 +118,8 @@
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -129,15 +132,39 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.authSessionLocation {
|
.authSessionLocation {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--text-primary-muted);
|
color: var(--text-primary-muted);
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locationText {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.locationSeparator {
|
||||||
|
background-color: var(--background-modifier-accent);
|
||||||
|
width: 0.25rem;
|
||||||
|
height: 0.25rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
display: inline-block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.8;
|
||||||
|
margin: 0 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
.lastUsed {
|
.lastUsed {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.authSessionActions {
|
.authSessionActions {
|
||||||
@@ -228,17 +255,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.devicesGrid {
|
.devicesGrid {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.devicesGrid {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.logoutSection {
|
.logoutSection {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -115,10 +115,10 @@ const AuthSession: React.FC<AuthSessionProps> = observer(
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div className={styles.authSessionLocation}>
|
<div className={styles.authSessionLocation}>
|
||||||
{authSession.clientLocation}
|
<span className={styles.locationText}>{authSession.clientLocation}</span>
|
||||||
{!isCurrent && (
|
{!isCurrent && (
|
||||||
<>
|
<>
|
||||||
<StatusDot />
|
<span aria-hidden className={styles.locationSeparator} />
|
||||||
<span className={styles.lastUsed}>
|
<span className={styles.lastUsed}>
|
||||||
{DateUtils.getShortRelativeDateString(authSession.approxLastUsedAt ?? new Date(0))}
|
{DateUtils.getShortRelativeDateString(authSession.approxLastUsedAt ?? new Date(0))}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {Trans, useLingui} from '@lingui/react/macro';
|
import {Trans, useLingui} from '@lingui/react/macro';
|
||||||
import {CaretDownIcon, CheckIcon, CopyIcon, GiftIcon, NetworkSlashIcon} from '@phosphor-icons/react';
|
import {CaretDownIcon, CheckIcon, CopyIcon, GiftIcon, NetworkSlashIcon, WarningCircleIcon} from '@phosphor-icons/react';
|
||||||
import {clsx} from 'clsx';
|
import {clsx} from 'clsx';
|
||||||
import {observer} from 'mobx-react-lite';
|
import {observer} from 'mobx-react-lite';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -31,6 +31,7 @@ import * as UserActionCreators from '~/actions/UserActionCreators';
|
|||||||
import {UserPremiumTypes} from '~/Constants';
|
import {UserPremiumTypes} from '~/Constants';
|
||||||
import {Form} from '~/components/form/Form';
|
import {Form} from '~/components/form/Form';
|
||||||
import {Input} from '~/components/form/Input';
|
import {Input} from '~/components/form/Input';
|
||||||
|
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
||||||
import {StatusSlate} from '~/components/modals/shared/StatusSlate';
|
import {StatusSlate} from '~/components/modals/shared/StatusSlate';
|
||||||
import {Button} from '~/components/uikit/Button/Button';
|
import {Button} from '~/components/uikit/Button/Button';
|
||||||
import {Spinner} from '~/components/uikit/Spinner';
|
import {Spinner} from '~/components/uikit/Spinner';
|
||||||
@@ -180,6 +181,7 @@ const GiftInventoryTab: React.FC = observer(() => {
|
|||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const [error, setError] = React.useState(false);
|
const [error, setError] = React.useState(false);
|
||||||
const [expandedGiftId, setExpandedGiftId] = React.useState<string | null>(null);
|
const [expandedGiftId, setExpandedGiftId] = React.useState<string | null>(null);
|
||||||
|
const isUnclaimed = !(UserStore.currentUser?.isClaimed() ?? false);
|
||||||
|
|
||||||
const giftCodeForm = useForm<GiftCodeFormInputs>({defaultValues: {code: ''}});
|
const giftCodeForm = useForm<GiftCodeFormInputs>({defaultValues: {code: ''}});
|
||||||
|
|
||||||
@@ -201,6 +203,10 @@ const GiftInventoryTab: React.FC = observer(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const fetchGifts = React.useCallback(async () => {
|
const fetchGifts = React.useCallback(async () => {
|
||||||
|
if (isUnclaimed) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
setError(false);
|
setError(false);
|
||||||
const userGifts = await GiftActionCreators.fetchUserGifts();
|
const userGifts = await GiftActionCreators.fetchUserGifts();
|
||||||
@@ -211,7 +217,7 @@ const GiftInventoryTab: React.FC = observer(() => {
|
|||||||
setError(true);
|
setError(true);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [isUnclaimed]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
fetchGifts();
|
fetchGifts();
|
||||||
@@ -225,6 +231,23 @@ const GiftInventoryTab: React.FC = observer(() => {
|
|||||||
fetchGifts();
|
fetchGifts();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isUnclaimed) {
|
||||||
|
return (
|
||||||
|
<StatusSlate
|
||||||
|
Icon={WarningCircleIcon}
|
||||||
|
title={<Trans>Claim your account</Trans>}
|
||||||
|
description={<Trans>Claim your account to redeem or manage Plutonium gift codes.</Trans>}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
text: <Trans>Claim Account</Trans>,
|
||||||
|
onClick: () => openClaimAccountModal({force: true}),
|
||||||
|
variant: 'primary',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -17,96 +17,221 @@
|
|||||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.container {
|
.page {
|
||||||
|
--report-max-width: 640px;
|
||||||
|
max-width: var(--report-max-width);
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem 0 2rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.25rem;
|
gap: 1rem;
|
||||||
max-width: 32rem;
|
|
||||||
margin: 0 auto;
|
|
||||||
text-align: left;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stepIndicator {
|
.breadcrumbs {
|
||||||
font-size: 0.75rem;
|
display: flex;
|
||||||
line-height: 1rem;
|
align-items: center;
|
||||||
letter-spacing: 0.06em;
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbShell {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 5;
|
||||||
|
padding-top: 0.3rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
min-height: 2.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbPlaceholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbStep {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
color 0.1s ease,
|
||||||
|
background 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbStep:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbStep:hover:not(:disabled) {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbActive {
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: default;
|
||||||
|
background: color-mix(in srgb, var(--background-modifier-accent) 60%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbActive:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbNumber {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.4rem;
|
||||||
|
height: 1.4rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--background-modifier-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbActive .breadcrumbNumber {
|
||||||
|
background: var(--brand-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbLabel {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbSeparator {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardHeader {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardBody {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--text-muted);
|
letter-spacing: 0.06em;
|
||||||
text-align: center;
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
margin-bottom: 0.25rem;
|
margin: 0;
|
||||||
text-align: center;
|
font-size: 1.3rem;
|
||||||
font-size: 1.25rem;
|
line-height: 1.6rem;
|
||||||
line-height: 1.75rem;
|
font-weight: 700;
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.025em;
|
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.description {
|
.description {
|
||||||
margin-bottom: 0.75rem;
|
margin: 0.2rem 0 0;
|
||||||
text-align: center;
|
font-size: 0.95rem;
|
||||||
font-size: 0.875rem;
|
line-height: 1.45rem;
|
||||||
line-height: 1.25rem;
|
color: var(--text-secondary);
|
||||||
color: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metaLine {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
line-height: 1.25rem;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metaValue {
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metaSpacer {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form {
|
.form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
.footerLinks {
|
||||||
margin-top: 1.25rem;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
align-items: center;
|
width: 100%;
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.footerRow {
|
@media (min-width: 640px) {
|
||||||
|
.actionRow {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionButton {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
align-items: center;
|
||||||
justify-content: center;
|
gap: 0.45rem;
|
||||||
flex-wrap: wrap;
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkButton {
|
||||||
|
all: unset;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkButton:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkSeparator {
|
||||||
|
color: var(--background-modifier-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.link {
|
.link {
|
||||||
font-size: 0.875rem;
|
font-size: 0.9rem;
|
||||||
line-height: 1.25rem;
|
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition-property: color;
|
|
||||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
transition-duration: 150ms;
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
transition: color 120ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link:hover {
|
.link:hover {
|
||||||
@@ -121,44 +246,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.errorBox {
|
.errorBox {
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
background-color: color-mix(in srgb, var(--status-danger) 15%, transparent);
|
background-color: color-mix(in srgb, var(--status-danger) 15%, transparent);
|
||||||
color: var(--status-danger);
|
color: var(--status-danger);
|
||||||
font-size: 0.875rem;
|
font-size: 0.9rem;
|
||||||
line-height: 1.25rem;
|
line-height: 1.3rem;
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.successBox {
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
background-color: color-mix(in srgb, var(--status-success) 12%, transparent);
|
|
||||||
color: var(--text-primary);
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.successLabel {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
line-height: 1rem;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.successValue {
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.helperText {
|
.helperText {
|
||||||
font-size: 0.75rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
text-align: left;
|
}
|
||||||
|
|
||||||
|
.mainColumn {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
78
fluxer_app/src/components/pages/report/ReportBreadcrumbs.tsx
Normal file
78
fluxer_app/src/components/pages/report/ReportBreadcrumbs.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Fluxer Contributors
|
||||||
|
*
|
||||||
|
* This file is part of Fluxer.
|
||||||
|
*
|
||||||
|
* Fluxer is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Fluxer 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 Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {Trans} from '@lingui/react/macro';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import React from 'react';
|
||||||
|
import styles from '../ReportPage.module.css';
|
||||||
|
import type {FlowStep} from './types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
current: FlowStep;
|
||||||
|
hasSelection: boolean;
|
||||||
|
hasEmail: boolean;
|
||||||
|
hasTicket: boolean;
|
||||||
|
onSelect: (step: FlowStep) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STEP_ORDER: Array<FlowStep> = ['selection', 'email', 'verification', 'details'];
|
||||||
|
|
||||||
|
export const ReportBreadcrumbs: React.FC<Props> = ({current, hasSelection, hasEmail, hasTicket, onSelect}) => {
|
||||||
|
const isEnabled = (step: FlowStep) => {
|
||||||
|
if (step === 'selection') return true;
|
||||||
|
if (step === 'email') return hasSelection;
|
||||||
|
if (step === 'verification') return hasEmail;
|
||||||
|
if (step === 'details') return hasTicket;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelMap: Record<FlowStep, React.ReactNode> = {
|
||||||
|
selection: <Trans>Choose</Trans>,
|
||||||
|
email: <Trans>Email</Trans>,
|
||||||
|
verification: <Trans>Code</Trans>,
|
||||||
|
details: <Trans>Details</Trans>,
|
||||||
|
complete: <Trans>Done</Trans>,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.breadcrumbs}>
|
||||||
|
{STEP_ORDER.map((step, index) => {
|
||||||
|
const active = current === step;
|
||||||
|
const clickable = !active && isEnabled(step);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={step}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={clsx(styles.breadcrumbStep, active && styles.breadcrumbActive)}
|
||||||
|
disabled={!clickable}
|
||||||
|
onClick={() => clickable && onSelect(step)}
|
||||||
|
>
|
||||||
|
<span className={styles.breadcrumbNumber}>{index + 1}</span>
|
||||||
|
<span className={styles.breadcrumbLabel}>{labelMap[step]}</span>
|
||||||
|
</button>
|
||||||
|
{index < STEP_ORDER.length - 1 && <span className={styles.breadcrumbSeparator}>›</span>}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReportBreadcrumbs;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Fluxer Contributors
|
||||||
|
*
|
||||||
|
* This file is part of Fluxer.
|
||||||
|
*
|
||||||
|
* Fluxer is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Fluxer 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 Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {Trans} from '@lingui/react/macro';
|
||||||
|
import {CheckCircleIcon} from '@phosphor-icons/react';
|
||||||
|
import type React from 'react';
|
||||||
|
import {StatusSlate} from '~/components/modals/shared/StatusSlate';
|
||||||
|
|
||||||
|
const FilledCheckCircleIcon: React.FC<React.ComponentProps<typeof CheckCircleIcon>> = (props) => (
|
||||||
|
<CheckCircleIcon weight="fill" {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onStartOver: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ReportStepComplete: React.FC<Props> = ({onStartOver}) => (
|
||||||
|
<StatusSlate
|
||||||
|
Icon={FilledCheckCircleIcon}
|
||||||
|
title={<Trans>Report submitted</Trans>}
|
||||||
|
description={<Trans>Thank you for helping keep Fluxer safe. We'll review your report as soon as possible.</Trans>}
|
||||||
|
iconStyle={{color: 'var(--status-success)'}}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
text: <Trans>Submit another report</Trans>,
|
||||||
|
onClick: onStartOver,
|
||||||
|
variant: 'secondary',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ReportStepComplete;
|
||||||
260
fluxer_app/src/components/pages/report/ReportStepDetails.tsx
Normal file
260
fluxer_app/src/components/pages/report/ReportStepDetails.tsx
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Fluxer Contributors
|
||||||
|
*
|
||||||
|
* This file is part of Fluxer.
|
||||||
|
*
|
||||||
|
* Fluxer is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Fluxer 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 Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {Trans, useLingui} from '@lingui/react/macro';
|
||||||
|
import type React from 'react';
|
||||||
|
import {Input, Textarea} from '~/components/form/Input';
|
||||||
|
import {Select, type SelectOption} from '~/components/form/Select';
|
||||||
|
import {Button} from '~/components/uikit/Button/Button';
|
||||||
|
import styles from '../ReportPage.module.css';
|
||||||
|
import type {FormValues, ReportType} from './types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
selectedType: ReportType;
|
||||||
|
formValues: FormValues;
|
||||||
|
categoryOptions: Array<SelectOption<string>>;
|
||||||
|
countryOptions: Array<SelectOption<string>>;
|
||||||
|
fieldErrors: Partial<Record<keyof FormValues, string>>;
|
||||||
|
errorMessage: string | null;
|
||||||
|
canSubmit: boolean;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
onFieldChange: (field: keyof FormValues, value: string) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
onStartOver: () => void;
|
||||||
|
onBack: () => void;
|
||||||
|
messageLinkOk: boolean;
|
||||||
|
userTargetOk: boolean;
|
||||||
|
guildTargetOk: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ReportStepDetails: React.FC<Props> = ({
|
||||||
|
selectedType,
|
||||||
|
formValues,
|
||||||
|
categoryOptions,
|
||||||
|
countryOptions,
|
||||||
|
fieldErrors,
|
||||||
|
errorMessage,
|
||||||
|
canSubmit,
|
||||||
|
isSubmitting,
|
||||||
|
onFieldChange,
|
||||||
|
onSubmit,
|
||||||
|
onStartOver,
|
||||||
|
onBack,
|
||||||
|
messageLinkOk,
|
||||||
|
userTargetOk,
|
||||||
|
guildTargetOk,
|
||||||
|
}) => {
|
||||||
|
const {t} = useLingui();
|
||||||
|
const hasFieldErrors = Object.values(fieldErrors).some((value) => Boolean(value));
|
||||||
|
const showGeneralError = Boolean(errorMessage && !hasFieldErrors);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.card}>
|
||||||
|
<header className={styles.cardHeader}>
|
||||||
|
<p className={styles.eyebrow}>
|
||||||
|
<Trans>Step 4</Trans>
|
||||||
|
</p>
|
||||||
|
<h1 className={styles.title}>
|
||||||
|
<Trans>Report details</Trans>
|
||||||
|
</h1>
|
||||||
|
<p className={styles.description}>
|
||||||
|
<Trans>Share only what's needed to help our team assess the content.</Trans>
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className={styles.cardBody}>
|
||||||
|
{showGeneralError && (
|
||||||
|
<div className={styles.errorBox} role="alert" aria-live="polite">
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form
|
||||||
|
className={styles.form}
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Select<string>
|
||||||
|
label={t`Violation Category`}
|
||||||
|
value={formValues.category}
|
||||||
|
options={categoryOptions}
|
||||||
|
error={fieldErrors.category}
|
||||||
|
onChange={(value) => onFieldChange('category', value)}
|
||||||
|
isSearchable={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selectedType === 'message' && (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
label={t`Message Link`}
|
||||||
|
type="url"
|
||||||
|
value={formValues.messageLink}
|
||||||
|
onChange={(e) => onFieldChange('messageLink', e.target.value)}
|
||||||
|
placeholder="https://fluxer.app/channels/..."
|
||||||
|
autoComplete="off"
|
||||||
|
error={fieldErrors.messageLink}
|
||||||
|
footer={
|
||||||
|
!formValues.messageLink.trim() ? undefined : !messageLinkOk ? (
|
||||||
|
<span className={styles.helperText}>
|
||||||
|
<Trans>That doesn't look like a valid URL.</Trans>
|
||||||
|
</span>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t`Reported User Tag (optional)`}
|
||||||
|
type="text"
|
||||||
|
value={formValues.messageUserTag}
|
||||||
|
onChange={(e) => onFieldChange('messageUserTag', e.target.value)}
|
||||||
|
placeholder="username#1234"
|
||||||
|
autoComplete="off"
|
||||||
|
error={fieldErrors.messageUserTag}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedType === 'user' && (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
label={t`User ID (optional)`}
|
||||||
|
type="text"
|
||||||
|
value={formValues.userId}
|
||||||
|
onChange={(e) => onFieldChange('userId', e.target.value)}
|
||||||
|
placeholder="123456789012345678"
|
||||||
|
autoComplete="off"
|
||||||
|
error={fieldErrors.userId}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t`User Tag (optional)`}
|
||||||
|
type="text"
|
||||||
|
value={formValues.userTag}
|
||||||
|
onChange={(e) => onFieldChange('userTag', e.target.value)}
|
||||||
|
placeholder="username#1234"
|
||||||
|
autoComplete="off"
|
||||||
|
error={fieldErrors.userTag}
|
||||||
|
footer={
|
||||||
|
userTargetOk ? undefined : (
|
||||||
|
<span className={styles.helperText}>
|
||||||
|
<Trans>Provide at least a user ID or a user tag.</Trans>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedType === 'guild' && (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
label={t`Guild (Community) ID`}
|
||||||
|
type="text"
|
||||||
|
value={formValues.guildId}
|
||||||
|
onChange={(e) => onFieldChange('guildId', e.target.value)}
|
||||||
|
placeholder="123456789012345678"
|
||||||
|
autoComplete="off"
|
||||||
|
error={fieldErrors.guildId}
|
||||||
|
footer={
|
||||||
|
guildTargetOk ? undefined : (
|
||||||
|
<span className={styles.helperText}>
|
||||||
|
<Trans>Guild ID is required.</Trans>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t`Invite Code (optional)`}
|
||||||
|
type="text"
|
||||||
|
value={formValues.inviteCode}
|
||||||
|
onChange={(e) => onFieldChange('inviteCode', e.target.value)}
|
||||||
|
placeholder="abcDEF12"
|
||||||
|
autoComplete="off"
|
||||||
|
error={fieldErrors.inviteCode}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label={t`Full Legal Name`}
|
||||||
|
type="text"
|
||||||
|
value={formValues.reporterFullName}
|
||||||
|
onChange={(e) => onFieldChange('reporterFullName', e.target.value)}
|
||||||
|
placeholder={t`First and last name`}
|
||||||
|
autoComplete="name"
|
||||||
|
error={fieldErrors.reporterFullName}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select<string>
|
||||||
|
label={t`Country of Residence`}
|
||||||
|
value={formValues.reporterCountry}
|
||||||
|
options={countryOptions}
|
||||||
|
error={fieldErrors.reporterCountry}
|
||||||
|
onChange={(value) => onFieldChange('reporterCountry', value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label={t`Your FluxerTag (optional)`}
|
||||||
|
type="text"
|
||||||
|
value={formValues.reporterFluxerTag}
|
||||||
|
onChange={(e) => onFieldChange('reporterFluxerTag', e.target.value)}
|
||||||
|
placeholder="username#1234"
|
||||||
|
error={fieldErrors.reporterFluxerTag}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
label={t`Additional Comments (optional)`}
|
||||||
|
value={formValues.additionalInfo}
|
||||||
|
onChange={(e) => onFieldChange('additionalInfo', e.target.value)}
|
||||||
|
placeholder={t`Describe what makes the content illegal`}
|
||||||
|
maxLength={1000}
|
||||||
|
minRows={3}
|
||||||
|
maxRows={6}
|
||||||
|
error={fieldErrors.additionalInfo}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={styles.actionRow}>
|
||||||
|
<Button
|
||||||
|
fitContent
|
||||||
|
type="submit"
|
||||||
|
disabled={!canSubmit || isSubmitting}
|
||||||
|
submitting={isSubmitting}
|
||||||
|
className={styles.actionButton}
|
||||||
|
>
|
||||||
|
<Trans>Submit DSA Report</Trans>
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" fitContent type="button" onClick={onBack} disabled={isSubmitting}>
|
||||||
|
<Trans>Back</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className={styles.footerLinks}>
|
||||||
|
<p className={styles.linkRow}>
|
||||||
|
<button type="button" className={styles.linkButton} onClick={onStartOver} disabled={isSubmitting}>
|
||||||
|
<Trans>Start over</Trans>
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReportStepDetails;
|
||||||
112
fluxer_app/src/components/pages/report/ReportStepEmail.tsx
Normal file
112
fluxer_app/src/components/pages/report/ReportStepEmail.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Fluxer Contributors
|
||||||
|
*
|
||||||
|
* This file is part of Fluxer.
|
||||||
|
*
|
||||||
|
* Fluxer is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Fluxer 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 Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {Trans, useLingui} from '@lingui/react/macro';
|
||||||
|
import type React from 'react';
|
||||||
|
import {Input} from '~/components/form/Input';
|
||||||
|
import {Button} from '~/components/uikit/Button/Button';
|
||||||
|
import styles from '../ReportPage.module.css';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
email: string;
|
||||||
|
errorMessage: string | null;
|
||||||
|
isSending: boolean;
|
||||||
|
onEmailChange: (value: string) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
onStartOver: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ReportStepEmail: React.FC<Props> = ({
|
||||||
|
email,
|
||||||
|
errorMessage,
|
||||||
|
isSending,
|
||||||
|
onEmailChange,
|
||||||
|
onSubmit,
|
||||||
|
onStartOver,
|
||||||
|
}) => {
|
||||||
|
const {t} = useLingui();
|
||||||
|
const normalizedEmail = email.trim();
|
||||||
|
const emailLooksValid = normalizedEmail.length > 0 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.card}>
|
||||||
|
<header className={styles.cardHeader}>
|
||||||
|
<p className={styles.eyebrow}>
|
||||||
|
<Trans>Step 2</Trans>
|
||||||
|
</p>
|
||||||
|
<h1 className={styles.title}>
|
||||||
|
<Trans>Verify your email</Trans>
|
||||||
|
</h1>
|
||||||
|
<p className={styles.description}>
|
||||||
|
<Trans>We'll send a short code to confirm you can receive updates about this report.</Trans>
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className={styles.cardBody}>
|
||||||
|
{errorMessage && (
|
||||||
|
<div className={styles.errorBox} role="alert" aria-live="polite">
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form
|
||||||
|
className={styles.form}
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
label={t`Email Address`}
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => onEmailChange(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={styles.actionRow}>
|
||||||
|
<Button
|
||||||
|
fitContent
|
||||||
|
type="submit"
|
||||||
|
disabled={!emailLooksValid || isSending}
|
||||||
|
submitting={isSending}
|
||||||
|
className={styles.actionButton}
|
||||||
|
>
|
||||||
|
<Trans>Send Verification Code</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
fitContent
|
||||||
|
type="button"
|
||||||
|
onClick={onStartOver}
|
||||||
|
disabled={isSending}
|
||||||
|
className={styles.actionButton}
|
||||||
|
>
|
||||||
|
<Trans>Start over</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReportStepEmail;
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Fluxer Contributors
|
||||||
|
*
|
||||||
|
* This file is part of Fluxer.
|
||||||
|
*
|
||||||
|
* Fluxer is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Fluxer 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 Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {Trans} from '@lingui/react/macro';
|
||||||
|
import type React from 'react';
|
||||||
|
import type {RadioOption} from '~/components/uikit/RadioGroup/RadioGroup';
|
||||||
|
import {RadioGroup} from '~/components/uikit/RadioGroup/RadioGroup';
|
||||||
|
import styles from '../ReportPage.module.css';
|
||||||
|
import type {ReportType} from './types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
reportTypeOptions: ReadonlyArray<RadioOption<ReportType>>;
|
||||||
|
selectedType: ReportType | null;
|
||||||
|
onSelect: (type: ReportType) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ReportStepSelection: React.FC<Props> = ({reportTypeOptions, selectedType, onSelect}) => {
|
||||||
|
return (
|
||||||
|
<div className={styles.card}>
|
||||||
|
<header className={styles.cardHeader}>
|
||||||
|
<p className={styles.eyebrow}>
|
||||||
|
<Trans>Step 1</Trans>
|
||||||
|
</p>
|
||||||
|
<h1 className={styles.title}>
|
||||||
|
<Trans>Report Illegal Content</Trans>
|
||||||
|
</h1>
|
||||||
|
<p className={styles.description}>
|
||||||
|
<Trans>Select what you want to report.</Trans>
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className={styles.cardBody}>
|
||||||
|
<RadioGroup<ReportType>
|
||||||
|
options={reportTypeOptions}
|
||||||
|
value={selectedType}
|
||||||
|
onChange={onSelect}
|
||||||
|
aria-label="Report Type"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReportStepSelection;
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Fluxer Contributors
|
||||||
|
*
|
||||||
|
* This file is part of Fluxer.
|
||||||
|
*
|
||||||
|
* Fluxer is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Fluxer 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 Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {Trans, useLingui} from '@lingui/react/macro';
|
||||||
|
import type React from 'react';
|
||||||
|
import {Input} from '~/components/form/Input';
|
||||||
|
import {Button} from '~/components/uikit/Button/Button';
|
||||||
|
import styles from '../ReportPage.module.css';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
email: string;
|
||||||
|
verificationCode: string;
|
||||||
|
errorMessage: string | null;
|
||||||
|
isVerifying: boolean;
|
||||||
|
isResending: boolean;
|
||||||
|
resendCooldownSeconds: number;
|
||||||
|
onChangeEmail: () => void;
|
||||||
|
onResend: () => void;
|
||||||
|
onVerify: () => void;
|
||||||
|
onCodeChange: (value: string) => void;
|
||||||
|
onStartOver: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ReportStepVerification: React.FC<Props> = ({
|
||||||
|
email,
|
||||||
|
verificationCode,
|
||||||
|
errorMessage,
|
||||||
|
isVerifying,
|
||||||
|
isResending,
|
||||||
|
resendCooldownSeconds,
|
||||||
|
onChangeEmail,
|
||||||
|
onResend,
|
||||||
|
onVerify,
|
||||||
|
onCodeChange,
|
||||||
|
onStartOver,
|
||||||
|
}) => {
|
||||||
|
const {t} = useLingui();
|
||||||
|
const codeForValidation = verificationCode.trim().toUpperCase();
|
||||||
|
const codeLooksValid = /^[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(codeForValidation);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.card}>
|
||||||
|
<header className={styles.cardHeader}>
|
||||||
|
<p className={styles.eyebrow}>
|
||||||
|
<Trans>Step 3</Trans>
|
||||||
|
</p>
|
||||||
|
<h1 className={styles.title}>
|
||||||
|
<Trans>Enter verification code</Trans>
|
||||||
|
</h1>
|
||||||
|
<p className={styles.description}>
|
||||||
|
<Trans>We sent a code to {email}.</Trans>
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className={styles.cardBody}>
|
||||||
|
{errorMessage && (
|
||||||
|
<div className={styles.errorBox} role="alert" aria-live="polite">
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form
|
||||||
|
className={styles.form}
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onVerify();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
label={t`Verification Code`}
|
||||||
|
type="text"
|
||||||
|
value={verificationCode}
|
||||||
|
onChange={(e) => onCodeChange(e.target.value)}
|
||||||
|
placeholder="ABCD-1234"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={styles.actionRow}>
|
||||||
|
<Button
|
||||||
|
fitContent
|
||||||
|
type="submit"
|
||||||
|
disabled={!codeLooksValid || isVerifying}
|
||||||
|
submitting={isVerifying}
|
||||||
|
className={styles.actionButton}
|
||||||
|
>
|
||||||
|
<Trans>Verify Code</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
fitContent
|
||||||
|
type="button"
|
||||||
|
onClick={onResend}
|
||||||
|
disabled={isResending || isVerifying || resendCooldownSeconds > 0}
|
||||||
|
submitting={isResending}
|
||||||
|
>
|
||||||
|
{resendCooldownSeconds > 0 ? (
|
||||||
|
<Trans>Resend ({resendCooldownSeconds}s)</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Resend code</Trans>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className={styles.footerLinks}>
|
||||||
|
<p className={styles.linkRow}>
|
||||||
|
<button type="button" className={styles.linkButton} onClick={onChangeEmail}>
|
||||||
|
<Trans>Change email</Trans>
|
||||||
|
</button>
|
||||||
|
<span aria-hidden="true" className={styles.linkSeparator}>
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
<button type="button" className={styles.linkButton} onClick={onStartOver}>
|
||||||
|
<Trans>Start over</Trans>
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReportStepVerification;
|
||||||
110
fluxer_app/src/components/pages/report/optionDescriptors.ts
Normal file
110
fluxer_app/src/components/pages/report/optionDescriptors.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Fluxer Contributors
|
||||||
|
*
|
||||||
|
* This file is part of Fluxer.
|
||||||
|
*
|
||||||
|
* Fluxer is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Fluxer 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 Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {msg} from '@lingui/core/macro';
|
||||||
|
import type {ReportType} from './types';
|
||||||
|
|
||||||
|
type MessageDescriptor = ReturnType<typeof msg>;
|
||||||
|
|
||||||
|
type SelectDescriptor = {
|
||||||
|
value: string;
|
||||||
|
label: MessageDescriptor;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RadioDescriptor<T> = {
|
||||||
|
value: T;
|
||||||
|
name: MessageDescriptor;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const REPORT_TYPE_OPTION_DESCRIPTORS: ReadonlyArray<RadioDescriptor<ReportType>> = [
|
||||||
|
{value: 'message', name: msg`Report a Message`},
|
||||||
|
{value: 'user', name: msg`Report a User Profile`},
|
||||||
|
{value: 'guild', name: msg`Report a Community`},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MESSAGE_CATEGORY_OPTIONS: ReadonlyArray<SelectDescriptor> = [
|
||||||
|
{value: '', label: msg`Select a category`},
|
||||||
|
{value: 'harassment', label: msg`Harassment or Bullying`},
|
||||||
|
{value: 'hate_speech', label: msg`Hate Speech`},
|
||||||
|
{value: 'violent_content', label: msg`Violent or Graphic Content`},
|
||||||
|
{value: 'spam', label: msg`Spam or Scam`},
|
||||||
|
{value: 'nsfw_violation', label: msg`NSFW Policy Violation`},
|
||||||
|
{value: 'illegal_activity', label: msg`Illegal Activity`},
|
||||||
|
{value: 'doxxing', label: msg`Sharing Personal Information`},
|
||||||
|
{value: 'self_harm', label: msg`Self-Harm or Suicide`},
|
||||||
|
{value: 'child_safety', label: msg`Child Safety Concerns`},
|
||||||
|
{value: 'malicious_links', label: msg`Malicious Links`},
|
||||||
|
{value: 'impersonation', label: msg`Impersonation`},
|
||||||
|
{value: 'other', label: msg`Other`},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const USER_CATEGORY_OPTIONS: ReadonlyArray<SelectDescriptor> = [
|
||||||
|
{value: '', label: msg`Select a category`},
|
||||||
|
{value: 'harassment', label: msg`Harassment or Bullying`},
|
||||||
|
{value: 'hate_speech', label: msg`Hate Speech`},
|
||||||
|
{value: 'spam_account', label: msg`Spam Account`},
|
||||||
|
{value: 'impersonation', label: msg`Impersonation`},
|
||||||
|
{value: 'underage_user', label: msg`Underage User`},
|
||||||
|
{value: 'inappropriate_profile', label: msg`Inappropriate Profile`},
|
||||||
|
{value: 'other', label: msg`Other`},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const GUILD_CATEGORY_OPTIONS: ReadonlyArray<SelectDescriptor> = [
|
||||||
|
{value: '', label: msg`Select a category`},
|
||||||
|
{value: 'harassment', label: msg`Harassment`},
|
||||||
|
{value: 'hate_speech', label: msg`Hate Speech`},
|
||||||
|
{value: 'extremist_community', label: msg`Extremist Community`},
|
||||||
|
{value: 'illegal_activity', label: msg`Illegal Activity`},
|
||||||
|
{value: 'child_safety', label: msg`Child Safety Concerns`},
|
||||||
|
{value: 'raid_coordination', label: msg`Raid Coordination`},
|
||||||
|
{value: 'spam', label: msg`Spam or Scam Community`},
|
||||||
|
{value: 'malware_distribution', label: msg`Malware Distribution`},
|
||||||
|
{value: 'other', label: msg`Other`},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const COUNTRY_OPTIONS: ReadonlyArray<SelectDescriptor> = [
|
||||||
|
{value: '', label: msg`Select a country`},
|
||||||
|
{value: 'AT', label: msg`Austria`},
|
||||||
|
{value: 'BE', label: msg`Belgium`},
|
||||||
|
{value: 'BG', label: msg`Bulgaria`},
|
||||||
|
{value: 'HR', label: msg`Croatia`},
|
||||||
|
{value: 'CY', label: msg`Cyprus`},
|
||||||
|
{value: 'CZ', label: msg`Czech Republic`},
|
||||||
|
{value: 'DK', label: msg`Denmark`},
|
||||||
|
{value: 'EE', label: msg`Estonia`},
|
||||||
|
{value: 'FI', label: msg`Finland`},
|
||||||
|
{value: 'FR', label: msg`France`},
|
||||||
|
{value: 'DE', label: msg`Germany`},
|
||||||
|
{value: 'GR', label: msg`Greece`},
|
||||||
|
{value: 'HU', label: msg`Hungary`},
|
||||||
|
{value: 'IE', label: msg`Ireland`},
|
||||||
|
{value: 'IT', label: msg`Italy`},
|
||||||
|
{value: 'LV', label: msg`Latvia`},
|
||||||
|
{value: 'LT', label: msg`Lithuania`},
|
||||||
|
{value: 'LU', label: msg`Luxembourg`},
|
||||||
|
{value: 'MT', label: msg`Malta`},
|
||||||
|
{value: 'NL', label: msg`Netherlands`},
|
||||||
|
{value: 'PL', label: msg`Poland`},
|
||||||
|
{value: 'PT', label: msg`Portugal`},
|
||||||
|
{value: 'RO', label: msg`Romania`},
|
||||||
|
{value: 'SK', label: msg`Slovakia`},
|
||||||
|
{value: 'SI', label: msg`Slovenia`},
|
||||||
|
{value: 'ES', label: msg`Spain`},
|
||||||
|
{value: 'SE', label: msg`Sweden`},
|
||||||
|
];
|
||||||
152
fluxer_app/src/components/pages/report/state.ts
Normal file
152
fluxer_app/src/components/pages/report/state.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Fluxer Contributors
|
||||||
|
*
|
||||||
|
* This file is part of Fluxer.
|
||||||
|
*
|
||||||
|
* Fluxer is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Fluxer 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 Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {type Action, INITIAL_FORM_VALUES, type State} from './types';
|
||||||
|
|
||||||
|
export const createInitialState = (): State => ({
|
||||||
|
selectedType: null,
|
||||||
|
flowStep: 'selection',
|
||||||
|
|
||||||
|
email: '',
|
||||||
|
verificationCode: '',
|
||||||
|
ticket: null,
|
||||||
|
|
||||||
|
formValues: {...INITIAL_FORM_VALUES},
|
||||||
|
|
||||||
|
isSendingCode: false,
|
||||||
|
isVerifying: false,
|
||||||
|
isSubmitting: false,
|
||||||
|
|
||||||
|
errorMessage: null,
|
||||||
|
successReportId: null,
|
||||||
|
|
||||||
|
resendCooldownSeconds: 0,
|
||||||
|
|
||||||
|
fieldErrors: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function reducer(state: State, action: Action): State {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'RESET_ALL':
|
||||||
|
return createInitialState();
|
||||||
|
|
||||||
|
case 'SELECT_TYPE':
|
||||||
|
return {
|
||||||
|
...createInitialState(),
|
||||||
|
selectedType: action.reportType,
|
||||||
|
flowStep: 'email',
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'GO_TO_SELECTION':
|
||||||
|
return {
|
||||||
|
...createInitialState(),
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'GO_TO_EMAIL':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
flowStep: 'email',
|
||||||
|
verificationCode: '',
|
||||||
|
ticket: null,
|
||||||
|
isVerifying: false,
|
||||||
|
errorMessage: null,
|
||||||
|
resendCooldownSeconds: 0,
|
||||||
|
fieldErrors: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'GO_TO_VERIFICATION':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
flowStep: 'verification',
|
||||||
|
verificationCode: '',
|
||||||
|
ticket: null,
|
||||||
|
errorMessage: null,
|
||||||
|
resendCooldownSeconds: 0,
|
||||||
|
fieldErrors: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'GO_TO_DETAILS':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
flowStep: 'details',
|
||||||
|
errorMessage: null,
|
||||||
|
fieldErrors: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'SET_ERROR':
|
||||||
|
return {...state, errorMessage: action.message};
|
||||||
|
|
||||||
|
case 'SET_EMAIL':
|
||||||
|
return {...state, email: action.email, errorMessage: null};
|
||||||
|
|
||||||
|
case 'SET_VERIFICATION_CODE':
|
||||||
|
return {...state, verificationCode: action.code, errorMessage: null};
|
||||||
|
|
||||||
|
case 'SET_TICKET':
|
||||||
|
return {...state, ticket: action.ticket};
|
||||||
|
|
||||||
|
case 'SET_FORM_FIELD':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
formValues: {...state.formValues, [action.field]: action.value},
|
||||||
|
errorMessage: null,
|
||||||
|
fieldErrors: {...state.fieldErrors, [action.field]: undefined},
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'SENDING_CODE':
|
||||||
|
return {...state, isSendingCode: action.value};
|
||||||
|
|
||||||
|
case 'VERIFYING':
|
||||||
|
return {...state, isVerifying: action.value};
|
||||||
|
|
||||||
|
case 'SUBMITTING':
|
||||||
|
return {...state, isSubmitting: action.value};
|
||||||
|
|
||||||
|
case 'SUBMIT_SUCCESS':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
successReportId: action.reportId,
|
||||||
|
flowStep: 'complete',
|
||||||
|
isSubmitting: false,
|
||||||
|
errorMessage: null,
|
||||||
|
fieldErrors: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'START_RESEND_COOLDOWN':
|
||||||
|
return {...state, resendCooldownSeconds: action.seconds};
|
||||||
|
|
||||||
|
case 'TICK_RESEND_COOLDOWN':
|
||||||
|
return {...state, resendCooldownSeconds: Math.max(0, state.resendCooldownSeconds - 1)};
|
||||||
|
|
||||||
|
case 'SET_FIELD_ERRORS':
|
||||||
|
return {...state, fieldErrors: action.errors};
|
||||||
|
|
||||||
|
case 'CLEAR_FIELD_ERRORS':
|
||||||
|
return {...state, fieldErrors: {}};
|
||||||
|
|
||||||
|
case 'CLEAR_FIELD_ERROR': {
|
||||||
|
const next = {...state.fieldErrors};
|
||||||
|
delete next[action.field];
|
||||||
|
return {...state, fieldErrors: next};
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
81
fluxer_app/src/components/pages/report/types.ts
Normal file
81
fluxer_app/src/components/pages/report/types.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Fluxer Contributors
|
||||||
|
*
|
||||||
|
* This file is part of Fluxer.
|
||||||
|
*
|
||||||
|
* Fluxer is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Fluxer 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 Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ReportType = 'message' | 'user' | 'guild';
|
||||||
|
export type FlowStep = 'selection' | 'email' | 'verification' | 'details' | 'complete';
|
||||||
|
|
||||||
|
export const INITIAL_FORM_VALUES = {
|
||||||
|
category: '',
|
||||||
|
reporterFullName: '',
|
||||||
|
reporterCountry: '',
|
||||||
|
reporterFluxerTag: '',
|
||||||
|
messageLink: '',
|
||||||
|
messageUserTag: '',
|
||||||
|
userId: '',
|
||||||
|
userTag: '',
|
||||||
|
guildId: '',
|
||||||
|
inviteCode: '',
|
||||||
|
additionalInfo: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FormValues = typeof INITIAL_FORM_VALUES;
|
||||||
|
|
||||||
|
export type State = {
|
||||||
|
selectedType: ReportType | null;
|
||||||
|
flowStep: FlowStep;
|
||||||
|
|
||||||
|
email: string;
|
||||||
|
verificationCode: string;
|
||||||
|
ticket: string | null;
|
||||||
|
|
||||||
|
formValues: FormValues;
|
||||||
|
|
||||||
|
isSendingCode: boolean;
|
||||||
|
isVerifying: boolean;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
|
||||||
|
errorMessage: string | null;
|
||||||
|
successReportId: string | null;
|
||||||
|
|
||||||
|
resendCooldownSeconds: number;
|
||||||
|
|
||||||
|
fieldErrors: Partial<Record<keyof FormValues, string>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Action =
|
||||||
|
| {type: 'RESET_ALL'}
|
||||||
|
| {type: 'SELECT_TYPE'; reportType: ReportType}
|
||||||
|
| {type: 'GO_TO_SELECTION'}
|
||||||
|
| {type: 'GO_TO_EMAIL'}
|
||||||
|
| {type: 'GO_TO_VERIFICATION'}
|
||||||
|
| {type: 'GO_TO_DETAILS'}
|
||||||
|
| {type: 'SET_ERROR'; message: string | null}
|
||||||
|
| {type: 'SET_EMAIL'; email: string}
|
||||||
|
| {type: 'SET_VERIFICATION_CODE'; code: string}
|
||||||
|
| {type: 'SET_TICKET'; ticket: string | null}
|
||||||
|
| {type: 'SET_FORM_FIELD'; field: keyof FormValues; value: string}
|
||||||
|
| {type: 'SENDING_CODE'; value: boolean}
|
||||||
|
| {type: 'VERIFYING'; value: boolean}
|
||||||
|
| {type: 'SUBMITTING'; value: boolean}
|
||||||
|
| {type: 'SUBMIT_SUCCESS'; reportId: string}
|
||||||
|
| {type: 'START_RESEND_COOLDOWN'; seconds: number}
|
||||||
|
| {type: 'TICK_RESEND_COOLDOWN'}
|
||||||
|
| {type: 'SET_FIELD_ERRORS'; errors: Partial<Record<keyof FormValues, string>>}
|
||||||
|
| {type: 'CLEAR_FIELD_ERRORS'}
|
||||||
|
| {type: 'CLEAR_FIELD_ERROR'; field: keyof FormValues};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user