[skip ci] feat: prepare for public release

This commit is contained in:
Hampus Kraft
2026-01-02 19:27:51 +00:00
parent 197b23757f
commit 5ae825fc7d
199 changed files with 38391 additions and 33358 deletions

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
custom: ['https://fluxer.app/donate']

View File

@@ -70,6 +70,9 @@ jobs:
echo "Deploying commit ${sha}"
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
uses: docker/setup-buildx-action@v3
@@ -89,6 +92,8 @@ jobs:
platforms: linux/amd64
cache-from: type=gha,scope=${{ env.CACHE_SCOPE }}
cache-to: type=gha,mode=max,scope=${{ env.CACHE_SCOPE }}
build-args: |
BUILD_TIMESTAMP=${{ env.BUILD_TIMESTAMP }}
env:
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false

View File

@@ -74,6 +74,9 @@ jobs:
echo "Deploying commit ${sha}"
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
uses: docker/setup-buildx-action@v3
@@ -93,7 +96,8 @@ jobs:
platforms: linux/amd64
cache-from: type=gha,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:
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false

View File

@@ -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]
> 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.

View File

@@ -25,8 +25,11 @@ import gleam/dynamic/decode
import gleam/http
import gleam/http/request
import gleam/httpc
import gleam/int
import gleam/io
import gleam/json
import gleam/option
import gleam/string
pub type Report {
Report(
@@ -194,10 +197,7 @@ pub fn list_reports(
"reported_guild_name",
decode.optional(decode.string),
)
use reported_guild_icon_hash <- decode.field(
"reported_guild_icon_hash",
decode.optional(decode.string),
)
let reported_guild_icon_hash = option.None
use reported_guild_invite_code <- decode.field(
"reported_guild_invite_code",
decode.optional(decode.string),
@@ -518,14 +518,15 @@ pub fn get_report_detail(
let context_message_decoder = {
use id <- decode.field("id", decode.string)
use channel_id <- decode.field(
"channel_id",
decode.optional(decode.string),
use channel_id <- decode.optional_field("channel_id", "", decode.string)
use author_id <- decode.optional_field("author_id", "", decode.string)
use author_username <- decode.optional_field(
"author_username",
"",
decode.string,
)
use author_id <- decode.field("author_id", decode.string)
use author_username <- decode.field("author_username", decode.string)
use content <- decode.field("content", decode.string)
use timestamp <- decode.field("timestamp", decode.string)
use content <- decode.optional_field("content", "", decode.string)
use timestamp <- decode.optional_field("timestamp", "", decode.string)
use attachments <- decode.optional_field(
"attachments",
[],
@@ -533,7 +534,7 @@ pub fn get_report_detail(
)
decode.success(Message(
id: id,
channel_id: option.unwrap(channel_id, ""),
channel_id: channel_id,
author_id: author_id,
author_username: author_username,
content: content,
@@ -608,8 +609,9 @@ pub fn get_report_detail(
"reported_guild_name",
decode.optional(decode.string),
)
use reported_guild_icon_hash <- decode.field(
use reported_guild_icon_hash <- decode.optional_field(
"reported_guild_icon_hash",
option.None,
decode.optional(decode.string),
)
use reported_guild_invite_code <- decode.field(
@@ -680,7 +682,15 @@ pub fn get_report_detail(
case json.parse(resp.body, report_decoder) {
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)
@@ -699,7 +709,20 @@ pub fn get_report_detail(
Error(Forbidden(message))
}
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
Ok(resp) -> {
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)
}
}
}

View File

@@ -100,12 +100,24 @@ pub fn page_with_refresh(
[a.attribute("lang", "en"), a.attribute("data-base-path", ctx.base_path)],
[
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")], [
h.div([a.class("flex h-screen")], [
sidebar(ctx, active_page),
h.div([a.class("ml-64 flex-1 flex flex-col")], [
h.div(
[
a.attribute("data-sidebar-overlay", ""),
a.class("fixed inset-0 bg-black/50 z-30 hidden lg:hidden"),
],
[],
),
h.div(
[
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-8")], [
h.div([a.class("max-w-7xl mx-auto")], [
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)])
@@ -114,7 +126,10 @@ pub fn page_with_refresh(
content,
]),
]),
],
),
]),
sidebar_interaction_script(),
]),
],
)
@@ -123,18 +138,37 @@ pub fn page_with_refresh(
fn sidebar(ctx: Context, active_page: String) {
h.div(
[
a.attribute("data-sidebar", ""),
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 flex items-center justify-between gap-3",
),
],
[
h.div([a.class("p-6 border-b border-neutral-800")], [
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(
[
a.class("flex-1 overflow-y-auto p-4 space-y-1 sidebar-scrollbar"),
@@ -304,11 +338,68 @@ fn header(
h.header(
[
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",
),
],
[
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(
[
href(ctx, "/logout"),
@@ -390,3 +481,58 @@ fn render_avatar(
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();
})();
",
)
}

View File

@@ -28,7 +28,7 @@ pub type Tab {
pub fn render_tabs(ctx: Context, tabs: List(Tab)) -> element.Element(a) {
h.div([a.class("border-b border-neutral-200 mb-6")], [
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) }),
),
])
@@ -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) {
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 ->
"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)])

View File

@@ -23,7 +23,7 @@ import lustre/attribute as a
import lustre/element
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"
@@ -469,7 +469,10 @@ pub fn flex_row(
pub fn flex_row_between(
children: List(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(
@@ -570,6 +573,7 @@ pub fn data_table(
h.div(
[a.class("bg-white border border-neutral-200 rounded-lg overflow-hidden")],
[
h.div([a.class("overflow-x-auto")], [
h.table([a.class("min-w-full divide-y divide-neutral-200")], [
h.thead([a.class("bg-neutral-50")], [
h.tr(
@@ -600,6 +604,7 @@ pub fn data_table(
}),
),
]),
]),
],
)
}
@@ -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),
element.element(
"svg",
@@ -637,7 +642,7 @@ pub fn custom_checkbox(
a.attribute("xmlns", "http://www.w3.org/2000/svg"),
a.attribute("viewBox", "0 0 256 256"),
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")], [
element.text(label),
h.div([a.class("flex-1 min-w-0")], [
h.span(
[
a.class(
"text-sm text-neutral-900 group-hover:text-neutral-700 leading-snug truncate",
),
],
[element.text(label)],
),
]),
])
}

View File

@@ -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_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_gift_codes_generate = "gift_codes:generate"
@@ -506,6 +510,8 @@ pub fn get_all_acls() -> List(String) {
acl_user_disable_suspicious,
acl_user_delete,
acl_user_cancel_bulk_message_deletion,
acl_pending_verification_view,
acl_pending_verification_review,
acl_beta_codes_generate,
acl_gift_codes_generate,
acl_guild_lookup,

View File

@@ -118,7 +118,13 @@ pub fn view(
h.div(
[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(
[
a.class(
"flex flex-col gap-4 sm:flex-row sm:items-start sm:gap-6",
),
],
[
case
avatar.get_guild_icon_url(
ctx.media_endpoint,
@@ -128,15 +134,28 @@ pub fn view(
)
{
option.Some(icon_url) ->
h.div([a.class("flex-shrink-0")], [
h.div(
[
a.class(
"flex-shrink-0 flex items-center sm:block justify-center",
),
],
[
h.img([
a.src(icon_url),
a.alt(guild_data.name),
a.class("w-24 h-24 rounded-full"),
]),
])
],
)
option.None ->
h.div([a.class("flex-shrink-0")], [
h.div(
[
a.class(
"flex-shrink-0 flex items-center sm:block justify-center",
),
],
[
h.div(
[
a.class(
@@ -149,12 +168,13 @@ pub fn view(
)),
],
),
])
],
)
},
ui.detail_header(guild_data.name, [
#(
"Guild ID:",
h.div([a.class("text-sm text-neutral-900")], [
h.div([a.class("text-sm text-neutral-900 break-all")], [
element.text(guild_data.id),
]),
),
@@ -171,7 +191,8 @@ pub fn view(
),
),
]),
]),
],
),
],
),
render_tabs(

View File

@@ -112,7 +112,7 @@ pub fn view(
fn render_search_form(ctx: Context, query: option.Option(String)) {
ui.card(ui.PaddingSmall, [
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([
a.type_("text"),
a.name("q"),
@@ -123,12 +123,12 @@ fn render_search_form(ctx: Context, query: option.Option(String)) {
),
a.attribute("autocomplete", "off"),
]),
ui.button_primary("Search", "submit", []),
ui.button_primary("Search", "submit", [a.class("w-full sm:w-auto")]),
h.a(
[
href(ctx, "/guilds"),
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")],
@@ -159,7 +159,7 @@ fn render_guild_card(ctx: Context, guild: guilds.GuildSearchResult) {
],
[
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
avatar.get_guild_icon_url(
ctx.media_endpoint,
@@ -169,15 +169,28 @@ fn render_guild_card(ctx: Context, guild: guilds.GuildSearchResult) {
)
{
option.Some(icon_url) ->
h.div([a.class("flex-shrink-0")], [
h.div(
[
a.class(
"flex-shrink-0 flex items-center sm:block justify-center",
),
],
[
h.img([
a.src(icon_url),
a.alt(guild.name),
a.class("w-16 h-16 rounded-full"),
]),
])
],
)
option.None ->
h.div([a.class("flex-shrink-0")], [
h.div(
[
a.class(
"flex-shrink-0 flex items-center sm:block justify-center",
),
],
[
h.div(
[
a.class(
@@ -186,10 +199,11 @@ fn render_guild_card(ctx: Context, guild: guilds.GuildSearchResult) {
],
[element.text(avatar.get_initials_from_name(guild.name))],
),
])
],
)
},
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")], [
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("text-sm text-neutral-600")], [
h.div([a.class("text-sm text-neutral-600 break-all")], [
element.text("ID: " <> guild.id),
]),
h.div([a.class("text-sm text-neutral-600")], [

View File

@@ -15,18 +15,16 @@
//// 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 fluxer_admin/acl
import fluxer_admin/api/common
import fluxer_admin/api/verifications
import fluxer_admin/avatar
import fluxer_admin/components/flash
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/constants
import fluxer_admin/user
import fluxer_admin/web.{
type Context, type Session, action, href, prepend_base_path,
}
import fluxer_admin/web.{type Context, type Session, action, href}
import gleam/int
import gleam/list
import gleam/option
@@ -47,6 +45,60 @@ const suspicious_user_agent_keywords = [
"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(
ctx: Context,
session: Session,
@@ -55,6 +107,12 @@ pub fn view(
) -> Response {
let limit = 50
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 {
Ok(response) -> {
@@ -75,72 +133,31 @@ pub fn view(
h.span(
[
a.class("body-sm text-neutral-600"),
a.attribute("data-review-progress", ""),
],
[
element.text(int.to_string(count) <> " remaining"),
a.attribute("data-remaining-total", "true"),
],
[element.text(int.to_string(count) <> " remaining")],
)
},
]),
]),
case can_review {
True -> selection_toolbar()
False -> element.none()
},
case list.is_empty(response.pending_verifications) {
True -> empty_state()
False ->
h.div(
[a.class("mt-4")],
list.append(
[review_deck.styles()],
list.append(review_deck.script_tags(), [
h.div([a.class("mt-4")], [
h.div(
[
a.attribute("data-review-deck", "true"),
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),
a.class("grid gap-4 md:grid-cols-2 xl:grid-cols-3"),
a.attribute("data-select-grid", "true"),
],
[
h.div(
[a.class("max-w-2xl mx-auto")],
list.map(response.pending_verifications, fn(pv) {
render_pending_verification_card(ctx, pv)
render_pending_verification_card(ctx, pv, can_review)
}),
),
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,
current_admin,
flash_data,
content,
h.div([], [content, pending_verifications_script()]),
)
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 _offset = page * 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) -> {
let fragment =
h.div(
[
a.attribute("data-review-fragment", "true"),
a.attribute("data-page", int.to_string(page)),
],
[a.class("grid gap-4 md:grid-cols-2 xl:grid-cols-3")],
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)
}
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)
}
@@ -198,6 +218,12 @@ pub fn view_single(
) -> Response {
let limit = 50
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 {
Ok(response) -> {
@@ -223,46 +249,14 @@ pub fn view_single(
[element.text("Back to all")],
),
]),
h.div(
[a.class("mt-4")],
list.append(
[review_deck.styles()],
list.append(review_deck.script_tags(), [
h.div(
[
a.attribute("data-review-deck", "true"),
a.attribute(
"data-empty-url",
prepend_base_path(ctx, "/pending-verifications"),
),
a.tabindex(0),
a.class("mt-4 max-w-3xl"),
a.attribute("data-select-grid", "true"),
],
[
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"),
render_pending_verification_card(ctx, pv, can_review),
],
[
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,
current_admin,
flash_data,
content,
h.div([], [content, pending_verifications_script()]),
)
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(
ctx: Context,
pv: verifications.PendingVerification,
can_review: Bool,
) -> element.Element(a) {
let metadata_warning = user_agent_warning(pv.metadata)
let geoip_hint = geoip_reason_value(pv.metadata)
h.div(
[
a.attribute("data-review-card", "true"),
a.attribute(
"data-direct-url",
prepend_base_path(ctx, "/pending-verifications/" <> pv.user_id),
),
a.class(
"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.attribute("data-select-card", pv.user_id),
],
[
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([
a.src(avatar.get_user_avatar_url(
ctx.media_endpoint,
@@ -487,6 +488,16 @@ fn render_pending_verification_card(
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(
[
@@ -515,12 +526,16 @@ fn render_pending_verification_card(
]),
],
),
case can_review {
True ->
h.div([a.class("pt-4 border-t border-neutral-200")], [
h.div([a.class("flex items-center justify-end gap-2 flex-wrap")], [
h.form(
[
a.method("post"),
action(ctx, "/pending-verifications?action=reject"),
a.attribute("data-review-submit", "left"),
a.class("inline-flex w-full"),
a.attribute("data-async", "true"),
a.attribute("data-confirm", "Reject this registration?"),
],
[
h.input([
@@ -528,42 +543,36 @@ fn render_pending_verification_card(
a.name("user_id"),
a.value(pv.user_id),
]),
],
),
h.form(
[
a.method("post"),
action(ctx, "/pending-verifications?action=approve"),
a.attribute("data-review-submit", "right"),
a.class("inline-flex w-full"),
],
[
h.input([
a.type_("hidden"),
a.name("user_id"),
a.value(pv.user_id),
]),
],
),
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.type_("submit"),
a.attribute("data-action-type", "reject"),
a.attribute("accesskey", "r"),
a.class(
"px-4 py-2 bg-red-600 text-white rounded-lg label hover:bg-red-700 transition-colors",
),
],
[element.text("Reject")],
),
],
),
h.form(
[
a.method("post"),
action(ctx, "/pending-verifications?action=approve"),
a.attribute("data-async", "true"),
],
[
h.input([
a.type_("hidden"),
a.name("user_id"),
a.value(pv.user_id),
]),
h.button(
[
a.attribute("data-review-action", "right"),
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",
),
@@ -572,6 +581,13 @@ fn render_pending_verification_card(
),
],
),
]),
])
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 {
case value {
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) {
let #(title, message) = case err {
common.Unauthorized -> #(

File diff suppressed because it is too large Load Diff

View File

@@ -121,8 +121,20 @@ pub fn view(
h.div(
[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([a.class("flex-shrink-0")], [
h.div(
[
a.class(
"flex flex-col gap-4 sm:flex-row sm:items-start sm:gap-6",
),
],
[
h.div(
[
a.class(
"flex-shrink-0 flex items-center sm:block justify-center",
),
],
[
h.img([
a.src(avatar.get_user_avatar_url(
ctx.media_endpoint,
@@ -135,9 +147,10 @@ pub fn view(
a.alt(user_data.username),
a.class("w-24 h-24 rounded-full"),
]),
]),
],
),
h.div([a.class("flex-1")], [
h.div([a.class("flex items-center gap-3 mb-3")], [
h.div([a.class("flex flex-wrap items-center gap-3 mb-3")], [
ui.heading_section(
user_data.username
<> "#"
@@ -159,7 +172,7 @@ pub fn view(
case list.is_empty(badges) {
False ->
h.div(
[a.class("flex items-center gap-2 mb-3")],
[a.class("flex items-center gap-2 mb-3 flex-wrap")],
list.map(badges, fn(b) {
h.img([
a.src(b.icon),
@@ -172,11 +185,11 @@ pub fn view(
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("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")], [
h.div([a.class("text-sm text-neutral-900 break-all")], [
element.text(user_data.id),
]),
]),
@@ -199,7 +212,8 @@ pub fn view(
},
]),
]),
]),
],
),
],
),
render_tabs(

View File

@@ -114,7 +114,7 @@ pub fn view(
fn render_search_form(ctx: Context, query: option.Option(String)) {
ui.card(ui.PaddingSmall, [
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([
a.type_("text"),
a.name("q"),
@@ -125,12 +125,12 @@ fn render_search_form(ctx: Context, query: option.Option(String)) {
),
a.attribute("autocomplete", "off"),
]),
ui.button_primary("Search", "submit", []),
ui.button_primary("Search", "submit", [a.class("w-full sm:w-auto")]),
h.a(
[
href(ctx, "/users"),
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")],

View File

@@ -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 {
case err {
common.Unauthorized -> "Unauthorized"
@@ -897,20 +910,49 @@ fn handle_authenticated_request(req: Request, ctx: Context) -> Response {
Get -> {
use user_session, current_admin <- with_session_and_admin(req, ctx)
let flash_data = flash.from_request(req)
let admin_acls = case current_admin {
option.Some(admin) -> admin.acls
_ -> []
}
case
acl.has_permission(
admin_acls,
constants.acl_pending_verification_view,
)
{
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 -> {
use user_session <- with_session(req, ctx)
let query = wisp.get_query(req)
let action = list.key_find(query, "action") |> option.from_result
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
_ -> []
}
case
acl.has_permission(
admin_acls,
constants.acl_pending_verification_review,
)
{
True ->
pending_verifications_page.handle_action(
req,
ctx,
@@ -918,6 +960,21 @@ fn handle_authenticated_request(req: Request, ctx: Context) -> Response {
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])
}
@@ -932,8 +989,32 @@ fn handle_authenticated_request(req: Request, ctx: Context) -> Response {
|> option.unwrap("1")
let page =
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])
}
@@ -985,21 +1066,26 @@ fn handle_authenticated_request(req: Request, ctx: Context) -> Response {
|> option.unwrap("0")
let page =
int.parse(page_str) |> option.from_result |> option.unwrap(0)
let fragment = get_bool_query(req, "fragment")
let table_view = get_bool_query(req, "view")
let sort =
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 {
True ->
reports_page.view_fragment(
ctx,
user_session,
search_query,
status_filter,
type_filter,
category_filter,
page,
)
False ->
reports_page.view_with_mode(
ctx,
user_session,
@@ -1010,10 +1096,10 @@ fn handle_authenticated_request(req: Request, ctx: Context) -> Response {
type_filter,
category_filter,
page,
table_view,
limit,
sort,
)
}
}
_ -> wisp.method_not_allowed([Get])
}
["reports", report_id] ->

View File

@@ -30,7 +30,7 @@ export const VerificationAdminController = (app: HonoApp) => {
app.post(
'/admin/pending-verifications/list',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.USER_LOOKUP),
requireAdminACL(AdminACLs.PENDING_VERIFICATION_VIEW),
Validator('json', z.object({limit: z.number().default(100)})),
async (ctx) => {
const adminService = ctx.get('adminService');
@@ -42,7 +42,7 @@ export const VerificationAdminController = (app: HonoApp) => {
app.post(
'/admin/pending-verifications/approve',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_FLAGS),
requireAdminACL(AdminACLs.PENDING_VERIFICATION_REVIEW),
Validator('json', z.object({user_id: Int64Type})),
async (ctx) => {
const adminService = ctx.get('adminService');
@@ -56,7 +56,7 @@ export const VerificationAdminController = (app: HonoApp) => {
app.post(
'/admin/pending-verifications/reject',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_FLAGS),
requireAdminACL(AdminACLs.PENDING_VERIFICATION_REVIEW),
Validator('json', z.object({user_id: Int64Type})),
async (ctx) => {
const adminService = ctx.get('adminService');
@@ -70,7 +70,7 @@ export const VerificationAdminController = (app: HonoApp) => {
app.post(
'/admin/pending-verifications/bulk-approve',
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)})),
async (ctx) => {
const adminService = ctx.get('adminService');
@@ -85,7 +85,7 @@ export const VerificationAdminController = (app: HonoApp) => {
app.post(
'/admin/pending-verifications/bulk-reject',
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)})),
async (ctx) => {
const adminService = ctx.get('adminService');

View File

@@ -165,7 +165,13 @@ export class AuthService implements IAuthService {
botMfaMirrorService?: BotMfaMirrorService,
authMfaService?: AuthMfaService,
) {
this.utilityService = new AuthUtilityService(repository, rateLimitService, gatewayService);
this.utilityService = new AuthUtilityService(
repository,
rateLimitService,
gatewayService,
inviteService,
pendingJoinInviteStore,
);
this.sessionService = new AuthSessionService(
repository,

View File

@@ -19,14 +19,17 @@
import crypto from 'node:crypto';
import {promisify} from 'node:util';
import type {UserID} from '~/BrandedTypes';
import {createInviteCode, type UserID} from '~/BrandedTypes';
import {APIErrorCodes, UserFlags} from '~/Constants';
import {AccessDeniedError, FluxerAPIError, InputValidationError, UnauthorizedError} from '~/Errors';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {IRateLimitService} from '~/infrastructure/IRateLimitService';
import type {PendingJoinInviteStore} from '~/infrastructure/PendingJoinInviteStore';
import type {InviteService} from '~/invite/InviteService';
import {Logger} from '~/Logger';
import {getUserSearchService} from '~/Meilisearch';
import type {User} from '~/Models';
import {createRequestCache} from '~/middleware/RequestCacheMiddleware';
import type {IUserRepository} from '~/user/IUserRepository';
import {mapUserToPrivateResponse} from '~/user/UserModel';
import * as AgeUtils from '~/utils/AgeUtils';
@@ -61,6 +64,8 @@ export class AuthUtilityService {
private repository: IUserRepository,
private rateLimitService: IRateLimitService,
private gatewayService: IGatewayService,
private inviteService: InviteService,
private pendingJoinInviteStore: PendingJoinInviteStore,
) {}
async generateSecureToken(length = 64): Promise<string> {
@@ -210,5 +215,29 @@ export class AuthUtilityService {
event: 'USER_UPDATE',
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);
}
}
}

View File

@@ -174,6 +174,11 @@ export abstract class BaseChannelAuthService {
async validateDMSendPermissions({channelId, userId}: {channelId: ChannelID; userId: UserID}): Promise<void> {
const channel = await this.channelRepository.channelData.findUnique(channelId);
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));
await this.dmPermissionValidator.validate({recipients, userId});
}

View File

@@ -72,6 +72,12 @@ export class CallService {
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 alreadyInCall = call ? call.voice_states.some((vs) => vs.user_id === userId.toString()) : false;
if (alreadyInCall) {

View File

@@ -217,6 +217,8 @@ export const AdminACLs = {
USER_DISABLE_SUSPICIOUS: 'user:disable:suspicious',
USER_DELETE: 'user:delete',
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',
GIFT_CODES_GENERATE: 'gift_codes:generate',

View File

@@ -24,7 +24,12 @@ import {applicationIdToUserId} from '~/BrandedTypes';
import {UserFlags, UserPremiumTypes} from '~/Constants';
import type {UserRow} from '~/database/CassandraTypes';
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 {EntityAssetService, PreparedAssetUpload} from '~/infrastructure/EntityAssetService';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
@@ -130,6 +135,10 @@ export class ApplicationService {
const owner = await this.deps.userRepository.findUniqueAssert(args.ownerUserId);
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 botUserId = applicationIdToUserId(applicationId);

View File

@@ -21,7 +21,13 @@ import type Stripe from 'stripe';
import type {UserID} from '~/BrandedTypes';
import {Config} from '~/Config';
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 type {User} from '~/Models';
import type {IUserRepository} from '~/user/IUserRepository';
@@ -212,6 +218,10 @@ export class StripeCheckoutService {
}
validateUserCanPurchase(user: User): void {
if (!user.passwordHash && !user.isBot) {
throw new UnclaimedAccountRestrictedError('make purchases');
}
if (user.flags & UserFlags.PREMIUM_PURCHASE_DISABLED) {
throw new PremiumPurchaseBlockedError();
}

View File

@@ -205,6 +205,8 @@ export const UserAccountController = (app: HonoApp) => {
const emailTokenProvided = emailToken !== undefined;
const isUnclaimed = !user.passwordHash;
if (isUnclaimed) {
const {username: _ignoredUsername, discriminator: _ignoredDiscriminator, ...rest} = userUpdateData;
userUpdateData = rest;
const allowed = new Set(['new_password']);
const disallowedField = Object.keys(userUpdateData).find((key) => !allowed.has(key));
if (disallowedField) {

View File

@@ -34,6 +34,7 @@ import {
HarvestOnCooldownError,
MaxBookmarksError,
MissingPermissionsError,
UnclaimedAccountRestrictedError,
UnknownChannelError,
UnknownHarvestError,
UnknownMessageError,
@@ -120,6 +121,10 @@ export class UserContentService {
throw new UnknownUserError();
}
if (!user.passwordHash && !user.isBot) {
throw new UnclaimedAccountRestrictedError('create beta codes');
}
const existingBetaCodes = await this.userContentRepository.listBetaCodes(userId);
const unclaimedCount = existingBetaCodes.filter((code) => !code.redeemerId).length;

View File

@@ -18,9 +18,11 @@
*/
import type {ChannelID, GuildID, UserID} from '~/BrandedTypes';
import {ChannelTypes} from '~/Constants';
import type {IChannelRepository} from '~/channel/IChannelRepository';
import {
FeatureTemporarilyDisabledError,
UnclaimedAccountRestrictedError,
UnknownChannelError,
UnknownGuildMemberError,
UnknownUserError,
@@ -114,6 +116,21 @@ export class VoiceService {
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 deaf = false;

View File

@@ -96,8 +96,8 @@ linux:
icon: electron-build-resources/icons-canary
category: Network
maintainer: Fluxer Contributors
synopsis: Chat that puts you first (Canary)
description: Canary build of Fluxer. Chat that puts you first. Built for friends, groups, and communities.
synopsis: Fluxer Canary
description: Fluxer Canary
executableName: fluxercanary
target:
- dir

View File

@@ -96,8 +96,8 @@ linux:
icon: electron-build-resources/icons-stable
category: Network
maintainer: Fluxer Contributors
synopsis: Chat that puts you first
description: Chat that puts you first. Built for friends, groups, and communities. Text, voice, and video. Open source and community-funded.
synopsis: Fluxer
description: Fluxer
executableName: fluxer
target:
- dir

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8">
<title>Fluxer</title>
<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="stylesheet" href="https://fluxerstatic.com/fonts/ibm-plex.css">
<link rel="manifest" href="/manifest.json">

View File

@@ -16,8 +16,7 @@
"undici",
"update-electron-app",
"@types/ws",
"electron-builder-squirrel-windows",
"esbuild"
"electron-builder-squirrel-windows"
],
"ignoreBinaries": ["go"],
"ignoreExportsUsedInFile": true,

View File

@@ -3,7 +3,7 @@
"version": "1.0.0",
"type": "module",
"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",
"author": "Fluxer Contributors <developers@fluxer.app>",
"sideEffects": [

View File

@@ -31,7 +31,7 @@ function generateManifest(cdnEndpointRaw) {
name: 'Fluxer',
short_name: 'Fluxer',
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: '/',
display: 'standalone',
orientation: 'portrait-primary',

View File

@@ -508,6 +508,7 @@ export const GatewayErrorCodes = {
VOICE_TOKEN_FAILED: 'VOICE_TOKEN_FAILED',
VOICE_GUILD_ID_MISSING: 'VOICE_GUILD_ID_MISSING',
VOICE_INVALID_GUILD_ID: 'VOICE_INVALID_GUILD_ID',
VOICE_UNCLAIMED_ACCOUNT: 'VOICE_UNCLAIMED_ACCOUNT',
DM_NOT_RECIPIENT: 'DM_NOT_RECIPIENT',
DM_INVALID_CHANNEL_TYPE: 'DM_INVALID_CHANNEL_TYPE',
UNKNOWN_ERROR: 'UNKNOWN_ERROR',

View File

@@ -73,6 +73,7 @@ export const fetch = async (userId: string, guildId?: string, force = false): Pr
query: {
...(guildId ? {guild_id: guildId} : {}),
with_mutual_friends: true,
with_mutual_guilds: true,
},
});
const profile = response.body;

View File

@@ -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);
}

View File

@@ -84,9 +84,6 @@ function NativeDatePicker({
error,
}: NativeDatePickerProps) {
const {t} = useLingui();
const _monthPlaceholder = t`Month`;
const _dayPlaceholder = t`Day`;
const _yearPlaceholder = t`Year`;
const dateOfBirthPlaceholder = t`Date of birth`;
const currentYear = new Date().getFullYear();

View File

@@ -131,6 +131,7 @@ import * as ChannelUtils from '~/utils/ChannelUtils';
import {getMutedText, getNotificationSettingsLabel} from '~/utils/ContextMenuUtils';
import {MAX_GROUP_DM_RECIPIENTS} from '~/utils/groupDmUtils';
import * as InviteUtils from '~/utils/InviteUtils';
import * as MemberListUtils from '~/utils/MemberListUtils';
import {buildChannelLink} from '~/utils/messageLinkUtils';
import * as NicknameUtils from '~/utils/NicknameUtils';
import * as RouterUtils from '~/utils/RouterUtils';
@@ -278,7 +279,18 @@ interface LazyMemberListGroupProps {
const LazyMemberListGroup = observer(
({guild, group, channelId, members, onMemberLongPress}: LazyMemberListGroupProps) => {
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 (
<div className={styles.memberGroupContainer}>
@@ -321,6 +333,7 @@ const LazyGuildMemberList = observer(
guildId: guild.id,
channelId: channel.id,
enabled,
allowInitialUnfocusedLoad: true,
});
const memberListState = MemberSidebarStore.getList(guild.id, channel.id);
@@ -364,21 +377,22 @@ const LazyGuildMemberList = observer(
const groupedItems: Map<string, Array<GuildMemberRecord>> = new Map();
const groups = memberListState.groups;
const seenMemberIds = new Set<string>();
for (const group of groups) {
groupedItems.set(group.id, []);
}
for (const [, item] of memberListState.items) {
if (item.type === 'member') {
let currentGroup: string | null = null;
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;
for (let i = groups.length - 1; i >= 0; i--) {
const group = groups[i];
const members = groupedItems.get(group.id);
if (members) {
members.push(member);
break;
}
if (!seenMemberIds.has(member.user.id)) {
seenMemberIds.add(member.user.id);
groupedItems.get(currentGroup)?.push(member);
}
}
}
@@ -734,6 +748,24 @@ export const ChannelDetailsBottomSheet: React.FC<ChannelDetailsBottomSheetProps>
}, []);
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 (
<>
@@ -910,30 +942,16 @@ export const ChannelDetailsBottomSheet: React.FC<ChannelDetailsBottomSheetProps>
)}
<div className={styles.membersHeader}>
<Trans>Members</Trans> {' '}
{isPersonalNotes ? 1 : isGroupDM ? channel.recipientIds.length + 1 : 2}
<Trans>Members</Trans> {dmMemberGroups.reduce((total, group) => total + group.count, 0)}
</div>
<div className={styles.membersListContainer}>
{(() => {
let memberIds: Array<string> = [];
if (isPersonalNotes) {
memberIds = currentUser ? [currentUser.id] : [];
} else if (isGroupDM) {
memberIds = [...channel.recipientIds];
if (currentUser && !memberIds.includes(currentUser.id)) {
memberIds.push(currentUser.id);
}
} else if (isDM) {
memberIds = [...channel.recipientIds];
if (currentUser && !memberIds.includes(currentUser.id)) {
memberIds.push(currentUser.id);
}
}
return memberIds.map((userId, index, arr) => {
const user = UserStore.getUser(userId);
if (!user) return null;
{dmMemberGroups.map((group) => (
<div key={group.id} className={styles.memberGroupContainer}>
<div className={styles.memberGroupHeader}>
{group.displayName} {group.count}
</div>
<div className={styles.memberGroupList}>
{group.users.map((user, index) => {
const isCurrentUser = user.id === currentUser?.id;
const isOwner = isGroupDM && channel.ownerId === user.id;
@@ -943,7 +961,11 @@ export const ChannelDetailsBottomSheet: React.FC<ChannelDetailsBottomSheetProps>
return (
<React.Fragment key={user.id}>
<button type="button" onClick={handleUserClick} className={styles.memberItemButton}>
<button
type="button"
onClick={handleUserClick}
className={styles.memberItemButton}
>
<StatusAwareAvatar user={user} size={40} />
<div className={styles.memberItemContent}>
<span className={styles.memberItemName}>
@@ -967,11 +989,13 @@ export const ChannelDetailsBottomSheet: React.FC<ChannelDetailsBottomSheetProps>
)}
</div>
</button>
{index < arr.length - 1 && <div className={styles.memberItemDivider} />}
{index < group.users.length - 1 && <div className={styles.memberItemDivider} />}
</React.Fragment>
);
});
})()}
})}
</div>
</div>
))}
</div>
</div>
)}

View File

@@ -78,6 +78,7 @@ import RuntimeConfigStore from '~/stores/RuntimeConfigStore';
import SelectedChannelStore from '~/stores/SelectedChannelStore';
import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore';
import UserProfileMobileStore from '~/stores/UserProfileMobileStore';
import UserStore from '~/stores/UserStore';
import * as CallUtils from '~/utils/CallUtils';
import {getMutedText} from '~/utils/ContextMenuUtils';
import * as InviteUtils from '~/utils/InviteUtils';
@@ -113,6 +114,7 @@ export const DMBottomSheet: React.FC<DMBottomSheetProps> = observer(({isOpen, on
const isRecipientBot = recipient?.bot;
const relationship = recipient ? RelationshipStore.getRelationship(recipient.id) : null;
const relationshipType = relationship?.type;
const currentUserUnclaimed = !(UserStore.currentUser?.isClaimed() ?? true);
const handleMarkAsRead = () => {
ReadStateActionCreators.ack(channel.id, true, true);
@@ -517,7 +519,8 @@ export const DMBottomSheet: React.FC<DMBottomSheetProps> = observer(({isOpen, on
});
} else if (
relationshipType !== RelationshipTypes.OUTGOING_REQUEST &&
relationshipType !== RelationshipTypes.BLOCKED
relationshipType !== RelationshipTypes.BLOCKED &&
!currentUserUnclaimed
) {
relationshipItems.push({
icon: <UserPlusIcon weight="fill" className={sharedStyles.icon} />,

View File

@@ -23,8 +23,11 @@ import {PhoneIcon, VideoCameraIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as CallActionCreators from '~/actions/CallActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import {ChannelTypes} from '~/Constants';
import type {ChannelRecord} from '~/records/ChannelRecord';
import CallStateStore from '~/stores/CallStateStore';
import UserStore from '~/stores/UserStore';
import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
import * as CallUtils from '~/utils/CallUtils';
import {ChannelHeaderIcon} from './ChannelHeaderIcon';
@@ -38,9 +41,20 @@ const VoiceCallButton = observer(({channel}: {channel: ChannelRecord}) => {
const hasActiveCall = CallStateStore.hasActiveCall(channel.id);
const participants = call ? CallStateStore.getParticipants(channel.id) : [];
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(
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) {
void CallActionCreators.leaveCall(channel.id);
} else if (hasActiveCall) {
@@ -50,7 +64,7 @@ const VoiceCallButton = observer(({channel}: {channel: ChannelRecord}) => {
await CallUtils.checkAndStartCall(channel.id, silent);
}
},
[channel.id, isInCall, hasActiveCall],
[channel.id, isInCall, hasActiveCall, blocked],
);
let label: string;
@@ -67,7 +81,13 @@ const VoiceCallButton = observer(({channel}: {channel: ChannelRecord}) => {
: t`Join Voice Call (${participantCount} participants)`;
}
} 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 (
@@ -76,6 +96,7 @@ const VoiceCallButton = observer(({channel}: {channel: ChannelRecord}) => {
label={label}
isSelected={isInCall}
onClick={handleClick}
disabled={blocked}
keybindAction="start_pm_call"
/>
);
@@ -90,9 +111,20 @@ const VideoCallButton = observer(({channel}: {channel: ChannelRecord}) => {
const hasActiveCall = CallStateStore.hasActiveCall(channel.id);
const participants = call ? CallStateStore.getParticipants(channel.id) : [];
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(
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) {
void CallActionCreators.leaveCall(channel.id);
} else if (hasActiveCall) {
@@ -102,7 +134,7 @@ const VideoCallButton = observer(({channel}: {channel: ChannelRecord}) => {
await CallUtils.checkAndStartCall(channel.id, silent);
}
},
[channel.id, isInCall, hasActiveCall],
[channel.id, isInCall, hasActiveCall, blocked],
);
let label: string;
@@ -119,10 +151,24 @@ const VideoCallButton = observer(({channel}: {channel: ChannelRecord}) => {
: t`Join Video Call (${participantCount} participants)`;
}
} 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 = {

View File

@@ -33,7 +33,7 @@ import type {UserRecord} from '~/records/UserRecord';
import AuthenticationStore from '~/stores/AuthenticationStore';
import MemberSidebarStore from '~/stores/MemberSidebarStore';
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 NicknameUtils from '~/utils/NicknameUtils';
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 {
group: GroupDMMemberGroup;
channelId: string;

View File

@@ -40,9 +40,7 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators';
import {MessagePreviewContext} from '~/Constants';
import {Message, type MessageBehaviorOverrides} from '~/components/channel/Message';
import {GroupDMAvatar} from '~/components/common/GroupDMAvatar';
import {MessageContextPrefix} from '~/components/shared/MessageContextPrefix/MessageContextPrefix';
import {Avatar} from '~/components/uikit/Avatar';
import {Button} from '~/components/uikit/Button/Button';
import {ContextMenuCloseProvider} from '~/components/uikit/ContextMenu/ContextMenu';
import {MenuGroup} from '~/components/uikit/ContextMenu/MenuGroup';
@@ -58,9 +56,7 @@ import ChannelSearchStore, {getChannelSearchContextId} from '~/stores/ChannelSea
import ChannelStore from '~/stores/ChannelStore';
import GuildNSFWAgreeStore from '~/stores/GuildNSFWAgreeStore';
import GuildStore from '~/stores/GuildStore';
import UserStore from '~/stores/UserStore';
import {applyChannelSearchHighlight, clearChannelSearchHighlight} from '~/utils/ChannelSearchHighlight';
import * as ChannelUtils from '~/utils/ChannelUtils';
import {goToMessage} from '~/utils/MessageNavigator';
import * as RouterUtils from '~/utils/RouterUtils';
import {tokenizeSearchQuery} from '~/utils/SearchQueryTokenizer';
@@ -78,14 +74,6 @@ import type {SearchMachineState} from './SearchResultsUtils';
import {areSegmentsEqual} from './SearchResultsUtils';
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 => {
if (!channel.guildId) {
return null;
@@ -101,37 +89,6 @@ const getChannelPath = (channel: ChannelRecord): string => {
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 {
channel: ChannelRecord;
searchQuery: string;
@@ -753,7 +710,6 @@ export const ChannelSearchResults = observer(
}
const channelGuild = getChannelGuild(messageChannel);
const _channelDisplayName = getChannelDisplayName(messageChannel);
const showGuildMeta = shouldShowGuildMetaForScope(
channelGuild,
(activeScope ?? DEFAULT_SCOPE_VALUE) as MessageSearchScope,

View File

@@ -31,6 +31,7 @@ import {
} from '~/components/embeds/EmbedCard/EmbedCard';
import cardStyles from '~/components/embeds/EmbedCard/EmbedCard.module.css';
import {useEmbedSkeletonOverride} from '~/components/embeds/EmbedCard/useEmbedSkeletonOverride';
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
import {Button} from '~/components/uikit/Button/Button';
import i18n from '~/i18n';
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 gift = giftState?.data;
const creator = UserStore.getUser(gift?.created_by?.id ?? '');
const isUnclaimed = !(UserStore.currentUser?.isClaimed() ?? false);
const shouldForceSkeleton = useEmbedSkeletonOverride();
React.useEffect(() => {
@@ -76,6 +78,10 @@ export const GiftEmbed = observer(function GiftEmbed({code}: GiftEmbedProps) {
const durationText = getGiftDurationText(i18n, gift);
const handleRedeem = async () => {
if (isUnclaimed) {
openClaimAccountModal({force: true});
return;
}
try {
await GiftActionCreators.redeem(i18n, code);
} catch (error) {
@@ -87,15 +93,20 @@ export const GiftEmbed = observer(function GiftEmbed({code}: GiftEmbedProps) {
<span className={styles.subRow}>{t`From ${creator.username}#${creator.discriminator}`}</span>
) : 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 =
gift.redeemed && !isUnclaimed ? (
<Button variant="primary" matchSkeletonHeight disabled>
{t`Gift Claimed`}
</Button>
) : (
<Button variant="primary" matchSkeletonHeight onClick={handleRedeem}>
{t`Claim Gift`}
<Button variant="primary" matchSkeletonHeight onClick={handleRedeem} disabled={gift.redeemed || isUnclaimed}>
{gift.redeemed ? t`Gift Claimed` : isUnclaimed ? t`Claim Account to Redeem` : t`Claim Gift`}
</Button>
);

View File

@@ -108,7 +108,6 @@ const MessageReactionItem = observer(
const emojiName = getEmojiName(reaction.emoji);
const emojiUrl = useEmojiURL({emoji: reaction.emoji, isHovering});
const _emojiIdentifier = reaction.emoji.id ?? reaction.emoji.name;
const isUnicodeEmoji = reaction.emoji.id == null;
const variants = {

View File

@@ -280,9 +280,7 @@ export const Messages = observer(function Messages({channel}: {channel: ChannelR
const data = payload as {channelId?: string; heightDelta?: number} | undefined;
if (data?.channelId && data.channelId !== channel.id) return;
if (scrollManager.isPinned()) {
scrollManager.handleScroll();
}
};
const onFocusBottommostMessage = (payload?: unknown) => {

View File

@@ -23,7 +23,7 @@ import {observer} from 'mobx-react-lite';
import {useEffect, useState} from 'react';
import * as ModalActionCreators 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 {UserSettingsModal} from '~/components/modals/UserSettingsModal';
import {Button} from '~/components/uikit/Button/Button';
@@ -103,7 +103,7 @@ export const UnclaimedAccountBarrier = observer(({onAction}: BarrierProps) => {
small={true}
onClick={() => {
onAction?.();
ModalActionCreators.push(modal(() => <ClaimAccountModal />));
openClaimAccountModal({force: true});
}}
>
<Trans>Claim Account</Trans>
@@ -234,7 +234,7 @@ export const UnclaimedDMBarrier = observer(({onAction}: BarrierProps) => {
small={true}
onClick={() => {
onAction?.();
ModalActionCreators.push(modal(() => <ClaimAccountModal />));
openClaimAccountModal({force: true});
}}
>
<Trans>Claim Account</Trans>

View File

@@ -205,6 +205,7 @@ export const DMChannelView = observer(({channelId}: DMChannelViewProps) => {
const title = isDM && displayName ? `@${displayName}` : displayName;
useFluxerDocumentTitle(title);
const isGroupDM = channel?.type === ChannelTypes.GROUP_DM;
const isPersonalNotes = channel?.type === ChannelTypes.DM_PERSONAL_NOTES;
const callHeaderState = useCallHeaderState(channel);
const call = callHeaderState.call;
const showCompactVoiceView = callHeaderState.controlsVariant === 'inCall';
@@ -411,7 +412,7 @@ export const DMChannelView = observer(({channelId}: DMChannelViewProps) => {
textarea={
isDM && isRecipientBlocked && recipient ? (
<BlockedUserBarrier userId={recipient.id} username={recipient.username} />
) : isCurrentUserUnclaimed ? (
) : isCurrentUserUnclaimed && isDM && !isPersonalNotes && !isGroupDM ? (
<UnclaimedDMBarrier />
) : (
<ChannelTextarea channel={channel} />

View File

@@ -17,15 +17,19 @@
* 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 {observer} from 'mobx-react-lite';
import React from 'react';
import * as RelationshipActionCreators from '~/actions/RelationshipActionCreators';
import {APIErrorCodes} from '~/Constants';
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 MobileLayoutStore from '~/stores/MobileLayoutStore';
import UserStore from '~/stores/UserStore';
import {getApiErrorCode} from '~/utils/ApiErrorUtils';
import styles from './AddFriendForm.module.css';
@@ -35,11 +39,30 @@ interface AddFriendFormProps {
export const AddFriendForm: React.FC<AddFriendFormProps> = observer(({onSuccess}) => {
const {t} = useLingui();
const [input, setInput] = React.useState('');
const [isLoading, setIsLoading] = React.useState(false);
const [resultStatus, setResultStatus] = React.useState<'success' | 'error' | 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 parts = input.split('#');
if (parts.length > 1) {

View File

@@ -29,6 +29,7 @@ import {AvatarStack} from '~/components/uikit/avatars/AvatarStack';
import {Button} from '~/components/uikit/Button/Button';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {StatusAwareAvatar} from '~/components/uikit/StatusAwareAvatar';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import type {ProfileRecord} from '~/records/ProfileRecord';
import GuildMemberStore from '~/stores/GuildMemberStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
@@ -92,6 +93,7 @@ export const DMWelcomeSection: React.FC<DMWelcomeSectionProps> = observer(functi
};
const hasMutualGuilds = mutualGuilds.length > 0;
const currentUserUnclaimed = !(UserStore.currentUser?.isClaimed() ?? true);
const shouldShowActionButton =
!user.bot &&
(relationshipType === undefined ||
@@ -103,12 +105,22 @@ export const DMWelcomeSection: React.FC<DMWelcomeSectionProps> = observer(functi
const renderActionButton = () => {
if (user.bot) return null;
switch (relationshipType) {
case undefined:
return (
<Button small={true} onClick={handleSendFriendRequest}>
case undefined: {
const tooltipText = t`Claim your account to send friend requests.`;
const button = (
<Button small={true} onClick={handleSendFriendRequest} disabled={currentUserUnclaimed}>
<Trans>Send Friend Request</Trans>
</Button>
);
if (currentUserUnclaimed) {
return (
<Tooltip text={tooltipText} maxWidth="xl">
<div>{button}</div>
</Tooltip>
);
}
return button;
}
case RelationshipTypes.INCOMING_REQUEST:
return (
<div className={styles.actionButtonsContainer}>

View File

@@ -457,8 +457,8 @@ export const EmbedGifv: FC<
const {width, aspectRatio} = style;
const containerStyle = {
'--embed-width': `${width}px`,
maxWidth: `${width}px`,
width: '100%',
maxWidth: '100%',
width,
aspectRatio,
} as React.CSSProperties;
@@ -488,7 +488,7 @@ export const EmbedGifv: FC<
type="gifv"
handlePress={openImagePreview}
>
<div className={styles.videoWrapper}>
<div className={styles.videoWrapper} style={aspectRatio ? {aspectRatio} : undefined}>
{(!loaded || error) && thumbHashURL && (
<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 containerRef = useRef<HTMLDivElement>(null);
const imgRef = useRef<HTMLImageElement>(null);
const isHoveredRef = useRef(false);
const defaultName = deriveDefaultNameFromMessage({
message,
@@ -622,16 +623,21 @@ export const EmbedGif: FC<GifvEmbedProps & {proxyURL: string; includeButton?: bo
useEffect(() => {
if (gifAutoPlay) return;
const img = imgRef.current;
const container = containerRef.current;
if (!img || !container) return;
if (!container) return;
const handleMouseEnter = () => {
if (FocusManager.isFocused() && img) {
isHoveredRef.current = true;
if (FocusManager.isFocused()) {
const img = imgRef.current;
if (img) {
img.src = optimizedAnimatedURL;
}
}
};
const handleMouseLeave = () => {
isHoveredRef.current = false;
const img = imgRef.current;
if (img) {
img.src = optimizedStaticURL;
}
@@ -646,6 +652,24 @@ export const EmbedGif: FC<GifvEmbedProps & {proxyURL: string; includeButton?: bo
};
}, [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) {
const {style} = mediaCalculator.calculate({width: naturalWidth, height: naturalHeight}, {forceScale: true});
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 containerStyle = {
'--embed-width': `${width}px`,
maxWidth: `${width}px`,
width: '100%',
maxWidth: '100%',
width,
aspectRatio,
} as React.CSSProperties;
@@ -716,7 +740,7 @@ export const EmbedGif: FC<GifvEmbedProps & {proxyURL: string; includeButton?: bo
contentHash={contentHash}
message={message}
>
<div className={styles.videoWrapper}>
<div className={styles.videoWrapper} style={aspectRatio ? {aspectRatio} : undefined}>
{(!loaded || error) && thumbHashURL && (
<img src={thumbHashURL} className={styles.thumbHashPlaceholder} alt={t`Loading placeholder`} />
)}

View File

@@ -251,6 +251,11 @@ export const EmbedImage: FC<EmbedImageProps> = observer(
aspectRatio: true,
},
);
const resolvedContainerStyle: React.CSSProperties = {
...containerStyle,
width: dimensions.width,
maxWidth: '100%',
};
const shouldRenderPlaceholder = error || !loaded;
@@ -295,7 +300,7 @@ export const EmbedImage: FC<EmbedImageProps> = observer(
<div className={styles.blurContainer}>
<div className={clsx(styles.rowContainer, isInline && styles.justifyEnd)}>
<div className={styles.innerContainer}>
<div className={styles.imageWrapper} style={containerStyle}>
<div className={styles.imageWrapper} style={resolvedContainerStyle}>
<div className={styles.imageContainer}>
{thumbHashURL && (
<div className={styles.thumbHashContainer}>
@@ -328,7 +333,7 @@ export const EmbedImage: FC<EmbedImageProps> = observer(
<div className={clsx(styles.rowContainer, isInline && styles.justifyEnd)}>
<MediaContainer
className={clsx(styles.mediaContainer, styles.cursorPointer)}
style={containerStyle}
style={resolvedContainerStyle}
showFavoriteButton={showFavoriteButton}
isFavorited={isFavorited}
onFavoriteClick={handleFavoriteClick}

View File

@@ -247,7 +247,7 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
}
: {
width: dimensions.width,
maxWidth: dimensions.width,
maxWidth: '100%',
aspectRatio,
};

View File

@@ -79,7 +79,6 @@ export const PickerSearchInput = React.forwardRef<HTMLInputElement, PickerSearch
{value, onChange, placeholder, inputRef, onKeyDown, maxLength = 100, showBackButton = false, onBackButtonClick},
forwardedRef,
) => {
const _inputElementRef = React.useRef<HTMLInputElement | null>(null);
const {t} = useLingui();
const inputElementRef = React.useRef<HTMLInputElement | null>(null);
const {canFocus, safeFocusTextarea} = useInputFocusManagement(inputElementRef);

View File

@@ -18,14 +18,13 @@
*/
import {FloatingPortal} from '@floating-ui/react';
import {Trans, useLingui} from '@lingui/react/macro';
import {PencilIcon, SealCheckIcon, SmileyIcon} from '@phosphor-icons/react';
import {Trans} from '@lingui/react/macro';
import {PencilIcon, SmileyIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {AnimatePresence, motion} from 'framer-motion';
import {observer} from 'mobx-react-lite';
import React from 'react';
import {GuildFeatures} from '~/Constants';
import {GuildIcon} from '~/components/popouts/GuildIcon';
import {EmojiAttributionSubtext, getEmojiAttribution} from '~/components/emojis/EmojiAttributionSubtext';
import {EmojiTooltipContent} from '~/components/uikit/EmojiTooltipContent/EmojiTooltipContent';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {useTooltipPortalRoot} from '~/components/uikit/Tooltip';
@@ -35,7 +34,6 @@ import {useReactionTooltip} from '~/hooks/useReactionTooltip';
import {type CustomStatus, getCustomStatusText, normalizeCustomStatus} from '~/lib/customStatus';
import UnicodeEmojis from '~/lib/UnicodeEmojis';
import EmojiStore from '~/stores/EmojiStore';
import GuildListStore from '~/stores/GuildListStore';
import GuildStore from '~/stores/GuildStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import PresenceStore from '~/stores/PresenceStore';
@@ -128,62 +126,6 @@ const getTooltipEmojiUrl = (status: CustomStatus): string | 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 {
status: CustomStatus;
children: React.ReactNode;
@@ -195,6 +137,13 @@ const StatusEmojiWithTooltip = observer(
({status, children, onClick, isButton = false}: StatusEmojiWithTooltipProps) => {
const tooltipPortalRoot = useTooltipPortalRoot();
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 => {
if (status.emojiId) {
@@ -257,7 +206,18 @@ const StatusEmojiWithTooltip = observer(
emoji={shouldUseNativeEmoji && status.emojiName && !status.emojiId ? status.emojiName : undefined}
emojiAlt={status.emojiName ?? undefined}
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>
</AnimatePresence>

View 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';

View File

@@ -17,14 +17,9 @@
* 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 {GuildFeatures} from '~/Constants';
import {GuildIcon} from '~/components/popouts/GuildIcon';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import {EmojiAttributionSubtext, getEmojiAttribution} from '~/components/emojis/EmojiAttributionSubtext';
import type {Emoji} from '~/stores/EmojiStore';
import GuildListStore from '~/stores/GuildListStore';
import GuildStore from '~/stores/GuildStore';
import styles from './EmojiInfoContent.module.css';
@@ -33,62 +28,25 @@ interface EmojiInfoContentProps {
}
export const EmojiInfoContent = observer(function EmojiInfoContent({emoji}: EmojiInfoContentProps) {
const {t} = useLingui();
const isCustomEmoji = Boolean(emoji.guildId || emoji.id);
if (!isCustomEmoji) {
return (
<div className={styles.container}>
<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);
const guild = emoji.guildId ? GuildStore.getGuild(emoji.guildId) : null;
const attribution = getEmojiAttribution({
emojiId: emoji.id,
guildId: emoji.guildId,
guild,
emojiName: emoji.name,
});
return (
<div className={styles.container}>
<span className={styles.text}>
<Trans>This is a custom emoji from</Trans>
</span>
<div className={styles.guildRow}>
<div className={styles.guildIcon}>
<GuildIcon id={guild.id} name={guild.name} icon={guild.icon} sizePx={20} />
</div>
<span className={styles.guildName}>{guild.name}</span>
{isVerified && (
<Tooltip text={t`Verified Community`} position="top">
<SealCheckIcon className={styles.verifiedIcon} />
</Tooltip>
)}
</div>
</div>
<EmojiAttributionSubtext
attribution={attribution}
classes={{
container: styles.container,
text: styles.text,
guildRow: styles.guildRow,
guildIcon: styles.guildIcon,
guildName: styles.guildName,
verifiedIcon: styles.verifiedIcon,
}}
/>
);
});

View File

@@ -242,6 +242,11 @@
opacity: 0.5;
}
.channelItemDisabled {
opacity: 0.6;
cursor: not-allowed;
}
.hoverAffordance {
display: none;
}

View File

@@ -76,6 +76,7 @@ import ReadStateStore from '~/stores/ReadStateStore';
import SelectedChannelStore from '~/stores/SelectedChannelStore';
import TrustedDomainStore from '~/stores/TrustedDomainStore';
import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore';
import UserStore from '~/stores/UserStore';
import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
import * as ChannelUtils from '~/utils/ChannelUtils';
@@ -194,10 +195,18 @@ export const ChannelItem = observer(
const showKeyboardAffordances = keyboardModeEnabled && isFocused;
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 isCurrentUserTimedOut = Boolean(currentMember?.isTimedOut());
const voiceBlockedForUnclaimed = channelIsVoice && isUnclaimed && !isGuildOwner;
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 =
channel.type === ChannelTypes.GUILD_VOICE &&
@@ -367,6 +376,13 @@ export const ChannelItem = observer(
});
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) {
onToggle?.();
return;
@@ -512,6 +528,7 @@ export const ChannelItem = observer(
contextMenuOpen && styles.contextMenuOpen,
showKeyboardAffordances && styles.keyboardFocus,
channelIsVoice && styles.channelItemVoice,
voiceBlockedForUnclaimed && styles.channelItemDisabled,
)}
onClick={handleSelect}
onContextMenu={handleContextMenu}

View File

@@ -200,6 +200,10 @@ export const GuildLayout = observer(({children}: {children: React.ReactNode}) =>
if (nagbarState.forceHideInvitesDisabled) return false;
if (nagbarState.forceInvitesDisabled) return true;
if (user && !user.isClaimed() && guild.ownerId === user.id) {
return false;
}
const hasInvitesDisabled = guild.features.has(GuildFeatures.INVITES_DISABLED);
if (!hasInvitesDisabled) return false;
@@ -218,6 +222,7 @@ export const GuildLayout = observer(({children}: {children: React.ReactNode}) =>
invitesDisabledDismissed,
nagbarState.forceInvitesDisabled,
nagbarState.forceHideInvitesDisabled,
user,
]);
const shouldShowStaffOnlyGuild = React.useMemo(() => {

View File

@@ -28,11 +28,9 @@ import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as DimensionActionCreators from '~/actions/DimensionActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as UserSettingsActionCreators from '~/actions/UserSettingsActionCreators';
import {ChannelTypes} from '~/Constants';
import {ClaimAccountModal} from '~/components/modals/ClaimAccountModal';
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import {ComponentDispatch} from '~/lib/ComponentDispatch';
import {Platform} from '~/lib/Platform';
@@ -330,7 +328,7 @@ export const GuildsLayout = observer(({children}: {children: React.ReactNode}) =
if (accountAgeMs < THIRTY_MINUTES_MS) return;
NagbarStore.markClaimAccountModalShown();
ModalActionCreators.push(modal(() => <ClaimAccountModal />));
openClaimAccountModal();
}, [isReady, user, location.pathname]);
const shouldShowSidebarDivider = !mobileLayout.enabled;

View File

@@ -19,12 +19,10 @@
import {Trans} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import {Nagbar} from '~/components/layout/Nagbar';
import {NagbarButton} from '~/components/layout/NagbarButton';
import {NagbarContent} from '~/components/layout/NagbarContent';
import {ClaimAccountModal} from '~/components/modals/ClaimAccountModal';
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
import UserStore from '~/stores/UserStore';
export const UnclaimedAccountNagbar = observer(({isMobile}: {isMobile: boolean}) => {
@@ -34,7 +32,7 @@ export const UnclaimedAccountNagbar = observer(({isMobile}: {isMobile: boolean})
}
const handleClaimAccount = () => {
ModalActionCreators.push(modal(() => <ClaimAccountModal />));
openClaimAccountModal({force: true});
};
return (

View File

@@ -24,6 +24,7 @@ import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {ChannelTypes} from '~/Constants';
import * as Modal from '~/components/modals/Modal';
import ChannelStore from '~/stores/ChannelStore';
import ConnectionStore from '~/stores/gateway/ConnectionStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import {isMobileExperienceEnabled} from '~/utils/mobileExperience';
import {
@@ -41,6 +42,7 @@ import type {ChannelSettingsTabType} from './utils/channelSettingsConstants';
export const ChannelSettingsModal: React.FC<ChannelSettingsModalProps> = observer(({channelId, initialMobileTab}) => {
const {t} = useLingui();
const channel = ChannelStore.getChannel(channelId);
const guildId = channel?.guildId;
const [selectedTab, setSelectedTab] = React.useState<ChannelSettingsTabType>('overview');
const availableTabs = React.useMemo(() => {
@@ -59,6 +61,12 @@ export const ChannelSettingsModal: React.FC<ChannelSettingsModalProps> = observe
const mobileNav = useMobileNavigation<ChannelSettingsTabType>(initialTab);
const {enabled: isMobile} = MobileLayoutStore;
React.useEffect(() => {
if (guildId) {
ConnectionStore.syncGuildIfNeeded(guildId, 'channel-settings-modal');
}
}, [guildId]);
React.useEffect(() => {
if (!channel) {
ModalActionCreators.pop();

View File

@@ -22,6 +22,7 @@ import {observer} from 'mobx-react-lite';
import {useEffect, useMemo, useState} from 'react';
import {useForm} from 'react-hook-form';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import * as UserActionCreators from '~/actions/UserActionCreators';
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 {Button} from '~/components/uikit/Button/Button';
import {useFormSubmit} from '~/hooks/useFormSubmit';
import ModalStore from '~/stores/ModalStore';
interface FormInputs {
email: string;
@@ -230,3 +232,20 @@ export const ClaimAccountModal = observer(() => {
</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,
);
};

View File

@@ -23,12 +23,14 @@ import {observer} from 'mobx-react-lite';
import React from 'react';
import * as GiftActionCreators from '~/actions/GiftActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import {Spinner} from '~/components/uikit/Spinner';
import i18n from '~/i18n';
import {UserRecord} from '~/records/UserRecord';
import GiftStore from '~/stores/GiftStore';
import UserStore from '~/stores/UserStore';
import {getGiftDurationText} from '~/utils/giftUtils';
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 gift = giftState?.data ?? null;
const [isRedeeming, setIsRedeeming] = React.useState(false);
const isUnclaimed = !(UserStore.currentUser?.isClaimed() ?? false);
React.useEffect(() => {
if (!giftState) {
@@ -64,6 +67,10 @@ export const GiftAcceptModal = observer(function GiftAcceptModal({code}: GiftAcc
};
const handleRedeem = async () => {
if (isUnclaimed) {
openClaimAccountModal({force: true});
return;
}
setIsRedeeming(true);
try {
await GiftActionCreators.redeem(i18n, code);
@@ -130,6 +137,42 @@ export const GiftAcceptModal = observer(function GiftAcceptModal({code}: GiftAcc
const renderGift = () => {
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 (
<>
<div className={styles.card}>

View File

@@ -25,6 +25,7 @@ import * as UnsavedChangesActionCreators from '~/actions/UnsavedChangesActionCre
import * as Modal from '~/components/modals/Modal';
import GuildSettingsModalStore from '~/stores/GuildSettingsModalStore';
import GuildStore from '~/stores/GuildStore';
import ConnectionStore from '~/stores/gateway/ConnectionStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import PermissionStore from '~/stores/PermissionStore';
import UnsavedChangesStore from '~/stores/UnsavedChangesStore';
@@ -79,6 +80,10 @@ export const GuildSettingsModal: React.FC<GuildSettingsModalProps> = observer(
const unsavedChangesStore = UnsavedChangesStore;
React.useEffect(() => {
ConnectionStore.syncGuildIfNeeded(guildId, 'guild-settings-modal');
}, [guildId]);
React.useEffect(() => {
if (!guild) {
ModalActionCreators.pop();

View File

@@ -230,6 +230,17 @@ export const InviteModal = observer(({channelId}: {channelId: string}) => {
<span className={styles.channelName}>{channel.name}</span>
</Trans>
</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}>
<Input
value={searchQuery}
@@ -248,7 +259,6 @@ export const InviteModal = observer(({channelId}: {channelId: string}) => {
<Spinner />
</div>
) : !showAdvanced ? (
<>
<RecipientList
recipients={recipients}
sendingTo={sendingTo}
@@ -262,19 +272,6 @@ export const InviteModal = observer(({channelId}: {channelId: string}) => {
onSearchQueryChange={setSearchQuery}
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}>
<Select

View File

@@ -217,6 +217,7 @@ const UserProfileMobileSheetContent: React.FC<UserProfileMobileSheetContentProps
const isCurrentUser = user.id === AuthenticationStore.currentUserId;
const relationship = RelationshipStore.getRelationship(user.id);
const relationshipType = relationship?.type;
const currentUserUnclaimed = !(UserStore.currentUser?.isClaimed() ?? true);
const guildMember = GuildMemberStore.getMember(profile?.guildId ?? guildId ?? '', user.id);
const memberRoles = profile?.guildId && guildMember ? guildMember.getSortedRoles() : [];
@@ -389,11 +390,14 @@ const UserProfileMobileSheetContent: React.FC<UserProfileMobileSheetContentProps
</button>
);
}
if (relationshipType === undefined && !currentUserUnclaimed) {
return (
<button type="button" onClick={handleSendFriendRequest} className={styles.actionButton}>
<UserPlusIcon className={styles.icon} />
</button>
);
}
return null;
};
return (

View File

@@ -1164,6 +1164,7 @@ export const UserProfileModal: UserProfileModalComponent = observer(
};
const renderActionButtons = () => {
const currentUserUnclaimed = !(UserStore.currentUser?.isClaimed() ?? true);
if (isCurrentUser && disableEditProfile) {
return (
<div className={userProfileModalStyles.actionButtons}>
@@ -1284,8 +1285,11 @@ export const UserProfileModal: UserProfileModalComponent = observer(
);
}
if (relationshipType === undefined && !isUserBot) {
const tooltipText = currentUserUnclaimed
? t`Claim your account to send friend requests.`
: t`Send Friend Request`;
return (
<Tooltip text={t`Send Friend Request`} maxWidth="xl">
<Tooltip text={tooltipText} maxWidth="xl">
<div>
<Button
variant="secondary"
@@ -1293,6 +1297,7 @@ export const UserProfileModal: UserProfileModalComponent = observer(
square={true}
icon={<UserPlusIcon className={userProfileModalStyles.buttonIcon} />}
onClick={handleSendFriendRequest}
disabled={currentUserUnclaimed}
/>
</div>
</Tooltip>

View File

@@ -32,11 +32,9 @@ import {ConfirmModal} from '~/components/modals/ConfirmModal';
import {ClientInfo} from '~/components/modals/components/ClientInfo';
import {LogoutModal} from '~/components/modals/components/LogoutModal';
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 {useSettingsContentKey} from '~/components/modals/hooks/useSettingsContentKey';
import {
MobileSectionNav,
MobileSettingsDangerItem,
MobileHeader as SharedMobileHeader,
} 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 {
getCategoryLabel,
getSectionIdsForTab,
getSectionsForTab,
type SettingsTab,
tabHasSections,
type UserSettingsTabType,
} from '~/components/modals/utils/settingsConstants';
import {filterSettingsTabsForDeveloperMode} from '~/components/modals/utils/settingsTabFilters';
import {Button} from '~/components/uikit/Button/Button';
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 {usePressable} from '~/hooks/usePressable';
import {usePushSubscriptions} from '~/hooks/usePushSubscriptions';
@@ -397,26 +392,7 @@ const headerFadeVariants = {
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 {
tabType: UserSettingsTabType;
scrollKey: string;
initialGuildId?: string;
initialSubtab?: string;
@@ -424,21 +400,9 @@ interface MobileContentWithScrollSpyProps {
}
const MobileContentWithScrollSpy: React.FC<MobileContentWithScrollSpyProps> = observer(
({tabType, scrollKey, initialGuildId, initialSubtab, currentTabComponent}) => {
const scrollerRef = React.useRef<ScrollerHandle | null>(null);
const scrollContainerRef = React.useRef<HTMLElement | null>(null);
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} />}
({scrollKey, initialGuildId, initialSubtab, currentTabComponent}) => {
return (
<Scroller className={styles.scrollerFlex} key={scrollKey} data-settings-scroll-container>
<div className={styles.contentContainer}>
{currentTabComponent &&
React.createElement(currentTabComponent, {
@@ -448,16 +412,6 @@ const MobileContentWithScrollSpy: React.FC<MobileContentWithScrollSpyProps> = ob
</div>
</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'}}
>
<MobileContentWithScrollSpy
tabType={currentTab.type}
scrollKey={scrollKey}
initialGuildId={initialGuildId}
initialSubtab={initialSubtab}

View File

@@ -94,6 +94,17 @@ export const PlutoniumContent: React.FC<{defaultGiftMode?: boolean}> = observer(
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 yearlyPrice = React.useMemo(() => getFormattedPrice(PricingTier.Yearly, 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}
handlePerksKeyDown={handlePerksKeyDown}
navigateToRedeemGift={navigateToRedeemGift}
handleSelectPlan={handleSelectPlan}
handleSelectPlan={handleSelectPlanGuarded}
handleOpenCustomerPortal={handleOpenCustomerPortal}
handleReactivateSubscription={handleReactivateSubscription}
handleCancelSubscription={handleCancelSubscription}
handleCommunityButtonPointerDown={handleCommunityButtonPointerDown}
handleCommunityButtonClick={handleCommunityButtonClick}
purchaseDisabled={purchaseDisabled}
purchaseDisabledTooltip={purchaseDisabledTooltip}
/>
<div className={styles.disclaimerContainer}>
<PurchaseDisclaimer align="center" isPremium />
@@ -245,7 +258,9 @@ export const PlutoniumContent: React.FC<{defaultGiftMode?: boolean}> = observer(
loadingCheckout={loadingCheckout}
loadingSlots={loadingSlots}
isVisionarySoldOut={isVisionarySoldOut}
handleSelectPlan={handleSelectPlan}
handleSelectPlan={handleSelectPlanGuarded}
purchaseDisabled={purchaseDisabled}
purchaseDisabledTooltip={purchaseDisabledTooltip}
/>
) : (
<GiftSection
@@ -257,7 +272,9 @@ export const PlutoniumContent: React.FC<{defaultGiftMode?: boolean}> = observer(
loadingCheckout={loadingCheckout}
loadingSlots={loadingSlots}
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}
loadingCheckout={loadingCheckout}
loadingSlots={loadingSlots}
handleSelectPlan={handleSelectPlan}
handleSelectPlan={handleSelectPlanGuarded}
purchaseDisabled={purchaseDisabled}
purchaseDisabledTooltip={purchaseDisabledTooltip}
/>
) : null}
@@ -302,7 +321,9 @@ export const PlutoniumContent: React.FC<{defaultGiftMode?: boolean}> = observer(
loadingCheckout={loadingCheckout}
loadingSlots={loadingSlots}
isVisionarySoldOut={isVisionarySoldOut}
handleSelectPlan={handleSelectPlan}
handleSelectPlan={handleSelectPlanGuarded}
purchaseDisabled={purchaseDisabled}
purchaseDisabledTooltip={purchaseDisabledTooltip}
/>
)}
</div>

View File

@@ -19,9 +19,7 @@
import {Trans} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import {ClaimAccountModal} from '~/components/modals/ClaimAccountModal';
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
import {Button} from '~/components/uikit/Button/Button';
import {WarningAlert} from '~/components/uikit/WarningAlert/WarningAlert';
@@ -30,7 +28,7 @@ export const UnclaimedAccountAlert = observer(() => {
<WarningAlert
title={<Trans>Unclaimed Account</Trans>}
actions={
<Button small={true} onClick={() => ModalActionCreators.push(modal(() => <ClaimAccountModal />))}>
<Button small={true} onClick={() => openClaimAccountModal({force: true})}>
<Trans>Claim Account</Trans>
</Button>
}

View File

@@ -17,12 +17,13 @@
* 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 type React from 'react';
import {Button} from '~/components/uikit/Button/Button';
import {PurchaseDisclaimer} from '../PurchaseDisclaimer';
import styles from './BottomCTASection.module.css';
import {PurchaseDisabledWrapper} from './PurchaseDisabledWrapper';
interface BottomCTASectionProps {
isGiftMode: boolean;
@@ -33,6 +34,8 @@ interface BottomCTASectionProps {
loadingSlots: boolean;
isVisionarySoldOut: boolean;
handleSelectPlan: (plan: 'monthly' | 'yearly' | 'visionary' | 'gift1Month' | 'gift1Year' | 'giftVisionary') => void;
purchaseDisabled?: boolean;
purchaseDisabledTooltip?: React.ReactNode;
}
export const BottomCTASection: React.FC<BottomCTASectionProps> = observer(
@@ -45,7 +48,12 @@ export const BottomCTASection: React.FC<BottomCTASectionProps> = observer(
loadingSlots,
isVisionarySoldOut,
handleSelectPlan,
purchaseDisabled = false,
purchaseDisabledTooltip,
}) => {
const {t} = useLingui();
const tooltipText: React.ReactNode = purchaseDisabledTooltip ?? t`Claim your account to purchase Fluxer Plutonium.`;
return (
<div className={styles.container}>
<h2 className={styles.title}>
@@ -54,55 +62,70 @@ export const BottomCTASection: React.FC<BottomCTASectionProps> = observer(
<div className={styles.buttonContainer}>
{!isGiftMode ? (
<>
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
<Button
variant="secondary"
onClick={() => handleSelectPlan('monthly')}
submitting={loadingCheckout || loadingSlots}
className={styles.button}
disabled={purchaseDisabled}
>
<Trans>Monthly {monthlyPrice}</Trans>
</Button>
</PurchaseDisabledWrapper>
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
<Button
variant="primary"
onClick={() => handleSelectPlan('yearly')}
submitting={loadingCheckout || loadingSlots}
className={styles.button}
disabled={purchaseDisabled}
>
<Trans>Yearly {yearlyPrice}</Trans>
</Button>
</PurchaseDisabledWrapper>
<PurchaseDisabledWrapper disabled={purchaseDisabled || isVisionarySoldOut} tooltipText={tooltipText}>
<Button
variant="primary"
onClick={() => handleSelectPlan('visionary')}
submitting={loadingCheckout || loadingSlots}
disabled={isVisionarySoldOut}
disabled={purchaseDisabled || isVisionarySoldOut}
className={styles.button}
>
{isVisionarySoldOut ? <Trans>Visionary Sold Out</Trans> : <Trans>Visionary {visionaryPrice}</Trans>}
</Button>
</PurchaseDisabledWrapper>
</>
) : (
<>
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
<Button
variant="secondary"
onClick={() => handleSelectPlan('gift1Year')}
submitting={loadingCheckout || loadingSlots}
className={styles.button}
disabled={purchaseDisabled}
>
<Trans>1 Year {yearlyPrice}</Trans>
</Button>
</PurchaseDisabledWrapper>
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
<Button
variant="primary"
onClick={() => handleSelectPlan('gift1Month')}
submitting={loadingCheckout || loadingSlots}
className={styles.button}
disabled={purchaseDisabled}
>
<Trans>1 Month {monthlyPrice}</Trans>
</Button>
</PurchaseDisabledWrapper>
<PurchaseDisabledWrapper disabled={purchaseDisabled || isVisionarySoldOut} tooltipText={tooltipText}>
<Button
variant="primary"
onClick={() => handleSelectPlan('giftVisionary')}
submitting={loadingCheckout || loadingSlots}
disabled={isVisionarySoldOut}
disabled={purchaseDisabled || isVisionarySoldOut}
className={styles.button}
>
{isVisionarySoldOut ? (
@@ -111,6 +134,7 @@ export const BottomCTASection: React.FC<BottomCTASectionProps> = observer(
<Trans>Visionary {visionaryPrice}</Trans>
)}
</Button>
</PurchaseDisabledWrapper>
</>
)}
</div>

View File

@@ -26,6 +26,7 @@ import {PricingCard} from '../PricingCard';
import gridStyles from '../PricingGrid.module.css';
import {PurchaseDisclaimer} from '../PurchaseDisclaimer';
import styles from './GiftSection.module.css';
import {PurchaseDisabledWrapper} from './PurchaseDisabledWrapper';
import {SectionHeader} from './SectionHeader';
interface GiftSectionProps {
@@ -38,6 +39,8 @@ interface GiftSectionProps {
loadingSlots: boolean;
isVisionarySoldOut: boolean;
handleSelectPlan: (plan: 'gift1Month' | 'gift1Year' | 'giftVisionary') => void;
purchaseDisabled?: boolean;
purchaseDisabledTooltip?: React.ReactNode;
}
export const GiftSection: React.FC<GiftSectionProps> = observer(
@@ -51,8 +54,11 @@ export const GiftSection: React.FC<GiftSectionProps> = observer(
loadingSlots,
isVisionarySoldOut,
handleSelectPlan,
purchaseDisabled = false,
purchaseDisabledTooltip,
}) => {
const {t} = useLingui();
const tooltipText: React.ReactNode = purchaseDisabledTooltip ?? t`Claim your account to purchase Fluxer Plutonium.`;
return (
<div ref={giftSectionRef}>
@@ -65,6 +71,7 @@ export const GiftSection: React.FC<GiftSectionProps> = observer(
/>
<div className={gridStyles.gridWrapper}>
<div className={gridStyles.gridThreeColumns}>
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
<PricingCard
title={t`1 Year Gift`}
price={yearlyPrice}
@@ -73,7 +80,10 @@ export const GiftSection: React.FC<GiftSectionProps> = observer(
onSelect={() => handleSelectPlan('gift1Year')}
buttonText={t`Buy Gift`}
isLoading={loadingCheckout || loadingSlots}
disabled={purchaseDisabled}
/>
</PurchaseDisabledWrapper>
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
<PricingCard
title={t`1 Month Gift`}
price={monthlyPrice}
@@ -82,7 +92,10 @@ export const GiftSection: React.FC<GiftSectionProps> = observer(
onSelect={() => handleSelectPlan('gift1Month')}
buttonText={t`Buy Gift`}
isLoading={loadingCheckout || loadingSlots}
disabled={purchaseDisabled}
/>
</PurchaseDisabledWrapper>
<PurchaseDisabledWrapper disabled={purchaseDisabled || isVisionarySoldOut} tooltipText={tooltipText}>
<PricingCard
title={t`Visionary Gift`}
price={visionaryPrice}
@@ -91,9 +104,10 @@ export const GiftSection: React.FC<GiftSectionProps> = observer(
onSelect={() => handleSelectPlan('giftVisionary')}
buttonText={t`Buy Gift`}
isLoading={loadingCheckout || loadingSlots}
disabled={isVisionarySoldOut}
disabled={purchaseDisabled || isVisionarySoldOut}
soldOut={isVisionarySoldOut}
/>
</PurchaseDisabledWrapper>
</div>
</div>
<div className={styles.footerContainer}>

View File

@@ -27,6 +27,7 @@ import gridStyles from '../PricingGrid.module.css';
import {PurchaseDisclaimer} from '../PurchaseDisclaimer';
import {ToggleButton} from '../ToggleButton';
import styles from './PricingSection.module.css';
import {PurchaseDisabledWrapper} from './PurchaseDisabledWrapper';
interface PricingSectionProps {
isGiftMode: boolean;
@@ -39,6 +40,8 @@ interface PricingSectionProps {
loadingSlots: boolean;
isVisionarySoldOut: boolean;
handleSelectPlan: (plan: 'monthly' | 'yearly' | 'visionary' | 'gift1Month' | 'gift1Year' | 'giftVisionary') => void;
purchaseDisabled?: boolean;
purchaseDisabledTooltip?: React.ReactNode;
}
export const PricingSection: React.FC<PricingSectionProps> = observer(
@@ -53,8 +56,11 @@ export const PricingSection: React.FC<PricingSectionProps> = observer(
loadingSlots,
isVisionarySoldOut,
handleSelectPlan,
purchaseDisabled = false,
purchaseDisabledTooltip,
}) => {
const {t} = useLingui();
const tooltipText: React.ReactNode = purchaseDisabledTooltip ?? t`Claim your account to purchase Fluxer Plutonium.`;
return (
<section className={styles.section}>
@@ -67,13 +73,17 @@ export const PricingSection: React.FC<PricingSectionProps> = observer(
<div className={gridStyles.gridThreeColumns}>
{!isGiftMode ? (
<>
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
<PricingCard
title={t`Monthly`}
price={monthlyPrice}
period={t`per month`}
onSelect={() => handleSelectPlan('monthly')}
isLoading={loadingCheckout || loadingSlots}
disabled={purchaseDisabled}
/>
</PurchaseDisabledWrapper>
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
<PricingCard
title={t`Yearly`}
price={yearlyPrice}
@@ -83,7 +93,10 @@ export const PricingSection: React.FC<PricingSectionProps> = observer(
onSelect={() => handleSelectPlan('yearly')}
buttonText={t`Upgrade Now`}
isLoading={loadingCheckout || loadingSlots}
disabled={purchaseDisabled}
/>
</PurchaseDisabledWrapper>
<PurchaseDisabledWrapper disabled={purchaseDisabled || isVisionarySoldOut} tooltipText={tooltipText}>
<PricingCard
title={t`Visionary`}
price={visionaryPrice}
@@ -91,12 +104,14 @@ export const PricingSection: React.FC<PricingSectionProps> = observer(
remainingSlots={loadingSlots ? undefined : visionarySlots?.remaining}
onSelect={() => handleSelectPlan('visionary')}
isLoading={loadingCheckout || loadingSlots}
disabled={isVisionarySoldOut}
disabled={purchaseDisabled || isVisionarySoldOut}
soldOut={isVisionarySoldOut}
/>
</PurchaseDisabledWrapper>
</>
) : (
<>
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
<PricingCard
title={t`1 Year Gift`}
price={yearlyPrice}
@@ -105,7 +120,10 @@ export const PricingSection: React.FC<PricingSectionProps> = observer(
onSelect={() => handleSelectPlan('gift1Year')}
buttonText={t`Buy Gift`}
isLoading={loadingCheckout || loadingSlots}
disabled={purchaseDisabled}
/>
</PurchaseDisabledWrapper>
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
<PricingCard
title={t`1 Month Gift`}
price={monthlyPrice}
@@ -114,7 +132,10 @@ export const PricingSection: React.FC<PricingSectionProps> = observer(
onSelect={() => handleSelectPlan('gift1Month')}
buttonText={t`Buy Gift`}
isLoading={loadingCheckout || loadingSlots}
disabled={purchaseDisabled}
/>
</PurchaseDisabledWrapper>
<PurchaseDisabledWrapper disabled={purchaseDisabled || isVisionarySoldOut} tooltipText={tooltipText}>
<PricingCard
title={t`Visionary Gift`}
price={visionaryPrice}
@@ -123,9 +144,10 @@ export const PricingSection: React.FC<PricingSectionProps> = observer(
onSelect={() => handleSelectPlan('giftVisionary')}
buttonText={t`Buy Gift`}
isLoading={loadingCheckout || loadingSlots}
disabled={isVisionarySoldOut}
disabled={purchaseDisabled || isVisionarySoldOut}
soldOut={isVisionarySoldOut}
/>
</PurchaseDisabledWrapper>
</>
)}
</div>

View File

@@ -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>
);
};

View File

@@ -17,12 +17,13 @@
* 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 {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {Button} from '~/components/uikit/Button/Button';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import type {UserRecord} from '~/records/UserRecord';
import {PerksButton} from '../PerksButton';
import type {GracePeriodInfo} from './hooks/useSubscriptionStatus';
@@ -61,6 +62,8 @@ interface SubscriptionCardProps {
handleCancelSubscription: () => void;
handleCommunityButtonPointerDown: (event: React.PointerEvent) => void;
handleCommunityButtonClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
purchaseDisabled?: boolean;
purchaseDisabledTooltip?: React.ReactNode;
}
export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
@@ -97,9 +100,25 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
handleCancelSubscription,
handleCommunityButtonPointerDown,
handleCommunityButtonClick,
purchaseDisabled = false,
purchaseDisabledTooltip,
}) => {
const {t} = useLingui();
const {isInGracePeriod, isExpired: isFullyExpired, graceEndDate} = gracePeriodInfo;
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 (
<div className={clsx(styles.card, subscriptionCardColorClass)}>
@@ -251,30 +270,47 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
<div className={styles.actions}>
{isGiftSubscription ? (
<>
<Button variant="inverted" onClick={navigateToRedeemGift} small className={styles.actionButton}>
{wrapIfDisabled(
<Button
variant="inverted"
onClick={navigateToRedeemGift}
small
className={styles.actionButton}
disabled={purchaseDisabled}
>
<Trans>Redeem Gift Code</Trans>
</Button>
{!isVisionary && !isVisionarySoldOut && (
</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>
</Button>,
'upgrade-gift-visionary',
purchaseDisabled,
)}
</>
) : (
<>
{hasEverPurchased && (
{hasEverPurchased &&
wrapIfDisabled(
<Button
variant="inverted"
onClick={shouldUseReactivateQuickAction ? handleReactivateSubscription : handleOpenCustomerPortal}
submitting={shouldUseReactivateQuickAction ? loadingReactivate : loadingPortal}
small
className={styles.actionButton}
disabled={purchaseDisabled && shouldUseReactivateQuickAction}
>
{isFullyExpired ? (
<Trans>Resubscribe</Trans>
@@ -287,7 +323,9 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
) : (
<Trans>Manage Subscription</Trans>
)}
</Button>
</Button>,
'manage-reactivate',
purchaseDisabled && shouldUseReactivateQuickAction,
)}
{isVisionary && (
@@ -305,16 +343,21 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
</Button>
)}
{!isVisionary && !isVisionarySoldOut && (
{!isVisionary &&
!isVisionarySoldOut &&
wrapIfDisabled(
<Button
variant="inverted"
onClick={() => handleSelectPlan('visionary')}
submitting={loadingCheckout || loadingSlots}
small
className={styles.actionButton}
disabled={purchaseDisabled}
>
<Trans>Upgrade to Visionary</Trans>
</Button>
</Button>,
'upgrade-visionary',
purchaseDisabled,
)}
{shouldUseCancelQuickAction && (

View File

@@ -23,6 +23,7 @@ import {observer} from 'mobx-react-lite';
import type React from 'react';
import type {VisionarySlots} from '~/actions/PremiumActionCreators';
import {Button} from '~/components/uikit/Button/Button';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import {VisionaryBenefit} from '../VisionaryBenefit';
import {SectionHeader} from './SectionHeader';
import styles from './VisionarySection.module.css';
@@ -36,6 +37,8 @@ interface VisionarySectionProps {
loadingCheckout: boolean;
loadingSlots: boolean;
handleSelectPlan: (plan: 'visionary') => void;
purchaseDisabled?: boolean;
purchaseDisabledTooltip?: React.ReactNode;
}
export const VisionarySection: React.FC<VisionarySectionProps> = observer(
@@ -48,9 +51,15 @@ export const VisionarySection: React.FC<VisionarySectionProps> = observer(
loadingCheckout,
loadingSlots,
handleSelectPlan,
purchaseDisabled = false,
purchaseDisabledTooltip,
}) => {
const {t} = useLingui();
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 (
<section className={styles.section}>
@@ -99,6 +108,22 @@ export const VisionarySection: React.FC<VisionarySectionProps> = observer(
{!isVisionary && visionarySlots && visionarySlots.remaining > 0 && (
<div className={styles.ctaContainer}>
{purchaseDisabled ? (
<Tooltip text={tooltipText}>
<div>
<Button
variant="primary"
onClick={() => handleSelectPlan('visionary')}
submitting={loadingCheckout || loadingSlots}
className={styles.ctaButton}
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')}
@@ -108,6 +133,7 @@ export const VisionarySection: React.FC<VisionarySectionProps> = observer(
<CrownIcon className={styles.ctaIcon} weight="fill" />
<Trans>Upgrade to Visionary {formatter.format(visionarySlots.remaining)} Left</Trans>
</Button>
)}
{isPremium && (
<p className={styles.disclaimer}>

View File

@@ -54,6 +54,7 @@ import GuildStore from '~/stores/GuildStore';
import PermissionStore from '~/stores/PermissionStore';
import RelationshipStore from '~/stores/RelationshipStore';
import UserProfileMobileStore from '~/stores/UserProfileMobileStore';
import UserStore from '~/stores/UserStore';
import * as CallUtils from '~/utils/CallUtils';
import * as NicknameUtils from '~/utils/NicknameUtils';
import * as PermissionUtils from '~/utils/PermissionUtils';
@@ -74,6 +75,7 @@ export const GuildMemberActionsSheet: FC<GuildMemberActionsSheetProps> = observe
const currentUserId = AuthenticationStore.currentUserId;
const isCurrentUser = user.id === currentUserId;
const isBot = user.bot;
const currentUserUnclaimed = !(UserStore.currentUser?.isClaimed() ?? true);
const relationship = RelationshipStore.getRelationship(user.id);
const relationshipType = relationship?.type;
@@ -262,7 +264,8 @@ export const GuildMemberActionsSheet: FC<GuildMemberActionsSheetProps> = observe
});
} else if (
relationshipType !== RelationshipTypes.OUTGOING_REQUEST &&
relationshipType !== RelationshipTypes.BLOCKED
relationshipType !== RelationshipTypes.BLOCKED &&
!currentUserUnclaimed
) {
relationshipItems.push({
icon: <UserPlusIcon className={styles.icon} />,

View File

@@ -36,13 +36,16 @@ interface StatusSlateProps {
description: React.ReactNode;
actions?: Array<StatusAction>;
fullHeight?: boolean;
iconClassName?: string;
iconStyle?: React.CSSProperties;
}
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 (
<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>
<p className={styles.description}>{description}</p>
{actions.length > 0 && (

View File

@@ -100,3 +100,7 @@
border-top: 1px solid var(--background-header-secondary);
padding-top: 1rem;
}
.claimButton {
align-self: flex-start;
}

View File

@@ -22,7 +22,7 @@ import {observer} from 'mobx-react-lite';
import type React from 'react';
import * as ModalActionCreators 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 {PasswordChangeModal} from '~/components/modals/PasswordChangeModal';
import {SettingsTabSection} from '~/components/modals/shared/SettingsTabLayout';
@@ -94,7 +94,7 @@ export const AccountTabContent: React.FC<AccountTabProps> = observer(
<Trans>No email address set</Trans>
</div>
</div>
<Button small={true} onClick={() => ModalActionCreators.push(modal(() => <ClaimAccountModal />))}>
<Button small={true} className={styles.claimButton} fitContent onClick={() => openClaimAccountModal()}>
<Trans>Add Email</Trans>
</Button>
</div>
@@ -134,7 +134,7 @@ export const AccountTabContent: React.FC<AccountTabProps> = observer(
<Trans>No password set</Trans>
</div>
</div>
<Button small={true} onClick={() => ModalActionCreators.push(modal(() => <ClaimAccountModal />))}>
<Button small={true} className={styles.claimButton} fitContent onClick={() => openClaimAccountModal()}>
<Trans>Set Password</Trans>
</Button>
</>

View File

@@ -96,3 +96,7 @@
display: flex;
gap: 0.5rem;
}
.claimButton {
align-self: flex-start;
}

View File

@@ -24,7 +24,7 @@ import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as UserActionCreators from '~/actions/UserActionCreators';
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 {MfaTotpDisableModal} from '~/components/modals/MfaTotpDisableModal';
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>
}
>
<Button onClick={() => ModalActionCreators.push(modal(() => <ClaimAccountModal />))}>
<Button className={styles.claimButton} fitContent onClick={() => openClaimAccountModal()}>
<Trans>Claim Account</Trans>
</Button>
</SettingsTabSection>

View File

@@ -17,7 +17,7 @@
* 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 {observer} from 'mobx-react-lite';
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 {Button} from '~/components/uikit/Button/Button';
import {Spinner} from '~/components/uikit/Spinner';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import type {DeveloperApplication} from '~/records/DeveloperApplicationRecord';
import UserStore from '~/stores/UserStore';
const ApplicationsTab: React.FC = observer(() => {
const {t} = useLingui();
const {checkUnsavedChanges} = useUnsavedChangesFlash('applications');
const {setContentKey} = useSettingsContentKey();
const store = ApplicationsTabStore;
const isUnclaimed = !(UserStore.currentUser?.isClaimed() ?? false);
React.useLayoutEffect(() => {
setContentKey(store.contentKey);
@@ -138,9 +142,19 @@ const ApplicationsTab: React.FC = observer(() => {
description={<Trans>Create and manage applications and bots for your account.</Trans>}
>
<div className={styles.buttonContainer}>
{isUnclaimed ? (
<Tooltip text={t`Claim your account to create applications.`}>
<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">
<BookOpenIcon weight="fill" size={18} className={styles.documentationIcon} />
<Trans>Read the Documentation (fluxer.dev)</Trans>

View File

@@ -25,6 +25,7 @@ import {observer} from 'mobx-react-lite';
import React from 'react';
import * as BetaCodeActionCreators from '~/actions/BetaCodeActionCreators';
import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators';
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
import {
SettingsTabContainer,
SettingsTabContent,
@@ -39,6 +40,7 @@ import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import type {BetaCodeRecord} from '~/records/BetaCodeRecord';
import BetaCodeStore from '~/stores/BetaCodeStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import UserStore from '~/stores/UserStore';
import * as DateUtils from '~/utils/DateUtils';
import styles from './BetaCodesTab.module.css';
@@ -247,10 +249,12 @@ const BetaCodesTab: React.FC = observer(() => {
const fetchStatus = BetaCodeStore.fetchStatus;
const allowance = BetaCodeStore.allowance;
const nextResetAt = BetaCodeStore.nextResetAt;
const isClaimed = UserStore.currentUser?.isClaimed() ?? false;
React.useEffect(() => {
if (!isClaimed) return;
BetaCodeActionCreators.fetch();
}, []);
}, [isClaimed]);
const sortedBetaCodes = React.useMemo(() => {
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`);
}, [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') {
return (
<div className={styles.spinnerContainer}>

View File

@@ -24,7 +24,7 @@ import {useCallback, useState} from 'react';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {testBulkDeleteAllMessages} from '~/actions/UserActionCreators';
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 {Button} from '~/components/uikit/Button/Button';
import type {GatewaySocket} from '~/lib/GatewaySocket';
@@ -67,7 +67,7 @@ export const ToolsTabContent: React.FC<ToolsTabContentProps> = observer(({socket
}, []);
const handleOpenClaimAccountModal = useCallback(() => {
ModalActionCreators.push(ModalActionCreators.modal(() => <ClaimAccountModal />));
openClaimAccountModal({force: true});
}, []);
if (shouldCrash) {

View File

@@ -58,6 +58,7 @@
flex: 1;
flex-direction: column;
gap: 0.75rem;
box-sizing: border-box;
padding: 1rem;
border-radius: 0.5rem;
border: 1px solid var(--background-header-secondary);
@@ -117,6 +118,8 @@
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
max-width: 100%;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -129,15 +132,39 @@
}
.authSessionLocation {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.875rem;
color: var(--text-primary-muted);
min-width: 0;
flex-wrap: nowrap;
}
.locationText {
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
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 {
font-size: 0.75rem;
flex-shrink: 0;
white-space: nowrap;
}
.authSessionActions {
@@ -228,17 +255,11 @@
}
.devicesGrid {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
display: flex;
flex-direction: column;
gap: 1rem;
}
@media (min-width: 1024px) {
.devicesGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.logoutSection {
display: flex;
flex-direction: column;

View File

@@ -115,10 +115,10 @@ const AuthSession: React.FC<AuthSessionProps> = observer(
</span>
<div className={styles.authSessionLocation}>
{authSession.clientLocation}
<span className={styles.locationText}>{authSession.clientLocation}</span>
{!isCurrent && (
<>
<StatusDot />
<span aria-hidden className={styles.locationSeparator} />
<span className={styles.lastUsed}>
{DateUtils.getShortRelativeDateString(authSession.approxLastUsedAt ?? new Date(0))}
</span>

View File

@@ -18,7 +18,7 @@
*/
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 {observer} from 'mobx-react-lite';
import React from 'react';
@@ -31,6 +31,7 @@ import * as UserActionCreators from '~/actions/UserActionCreators';
import {UserPremiumTypes} from '~/Constants';
import {Form} from '~/components/form/Form';
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 {Spinner} from '~/components/uikit/Spinner';
@@ -180,6 +181,7 @@ const GiftInventoryTab: React.FC = observer(() => {
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(false);
const [expandedGiftId, setExpandedGiftId] = React.useState<string | null>(null);
const isUnclaimed = !(UserStore.currentUser?.isClaimed() ?? false);
const giftCodeForm = useForm<GiftCodeFormInputs>({defaultValues: {code: ''}});
@@ -201,6 +203,10 @@ const GiftInventoryTab: React.FC = observer(() => {
});
const fetchGifts = React.useCallback(async () => {
if (isUnclaimed) {
setLoading(false);
return;
}
try {
setError(false);
const userGifts = await GiftActionCreators.fetchUserGifts();
@@ -211,7 +217,7 @@ const GiftInventoryTab: React.FC = observer(() => {
setError(true);
setLoading(false);
}
}, []);
}, [isUnclaimed]);
React.useEffect(() => {
fetchGifts();
@@ -225,6 +231,23 @@ const GiftInventoryTab: React.FC = observer(() => {
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 (
<div className={styles.container}>
<div>

View File

@@ -17,96 +17,221 @@
* 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;
flex-direction: column;
gap: 1.25rem;
max-width: 32rem;
margin: 0 auto;
text-align: left;
gap: 1rem;
}
.stepIndicator {
font-size: 0.75rem;
line-height: 1rem;
letter-spacing: 0.06em;
.breadcrumbs {
display: flex;
align-items: center;
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;
color: var(--text-muted);
text-align: center;
letter-spacing: 0.06em;
font-size: 0.75rem;
color: var(--text-tertiary);
}
.title {
margin-bottom: 0.25rem;
text-align: center;
font-size: 1.25rem;
line-height: 1.75rem;
font-weight: 600;
letter-spacing: 0.025em;
margin: 0;
font-size: 1.3rem;
line-height: 1.6rem;
font-weight: 700;
color: var(--text-primary);
}
.description {
margin-bottom: 0.75rem;
text-align: center;
font-size: 0.875rem;
line-height: 1.25rem;
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);
margin: 0.2rem 0 0;
font-size: 0.95rem;
line-height: 1.45rem;
color: var(--text-secondary);
}
.form {
display: flex;
flex-direction: column;
gap: 1rem;
align-items: stretch;
}
.footer {
margin-top: 1.25rem;
.footerLinks {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.actionRow {
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: center;
text-align: center;
width: 100%;
}
.footerRow {
@media (min-width: 640px) {
.actionRow {
flex-direction: row;
align-items: center;
justify-content: flex-start;
}
}
.actionButton {
align-self: flex-start;
}
.linkRow {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem;
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 {
font-size: 0.875rem;
line-height: 1.25rem;
font-size: 0.9rem;
color: var(--text-tertiary);
text-decoration: none;
background: none;
border: none;
cursor: pointer;
transition-property: color;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
padding: 0;
transition: color 120ms ease;
}
.link:hover {
@@ -121,44 +246,24 @@
}
.errorBox {
margin-bottom: 0.5rem;
padding: 0.75rem 1rem;
border-radius: var(--radius-lg);
background-color: color-mix(in srgb, var(--status-danger) 15%, transparent);
color: var(--status-danger);
font-size: 0.875rem;
line-height: 1.25rem;
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;
font-size: 0.9rem;
line-height: 1.3rem;
}
.helperText {
font-size: 0.75rem;
font-size: 0.8rem;
color: var(--text-muted);
text-align: left;
}
.mainColumn {
min-width: 0;
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
margin: 0;
}

View File

@@ -17,327 +17,138 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Trans, useLingui} from '@lingui/react/macro';
import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import 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 {RadioGroup, type RadioOption} from '~/components/uikit/RadioGroup/RadioGroup';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import type {SelectOption} from '~/components/form/Select';
import type {RadioOption} from '~/components/uikit/RadioGroup/RadioGroup';
import {AuthLayoutContext} from '~/contexts/AuthLayoutContext';
import {Endpoints} from '~/Endpoints';
import {useFluxerDocumentTitle} from '~/hooks/useFluxerDocumentTitle';
import HttpClient from '~/lib/HttpClient';
import styles from './ReportPage.module.css';
import {
COUNTRY_OPTIONS,
GUILD_CATEGORY_OPTIONS,
MESSAGE_CATEGORY_OPTIONS,
REPORT_TYPE_OPTION_DESCRIPTORS,
USER_CATEGORY_OPTIONS,
} from './report/optionDescriptors';
import ReportBreadcrumbs from './report/ReportBreadcrumbs';
import ReportStepComplete from './report/ReportStepComplete';
import ReportStepDetails from './report/ReportStepDetails';
import ReportStepEmail from './report/ReportStepEmail';
import ReportStepSelection from './report/ReportStepSelection';
import ReportStepVerification from './report/ReportStepVerification';
import {createInitialState, reducer} from './report/state';
import type {FlowStep, FormValues, ReportType} from './report/types';
import {
EMAIL_REGEX,
formatVerificationCodeInput,
isValidHttpUrl,
normalizeLikelyUrl,
VERIFICATION_CODE_REGEX,
} from './report/validators';
type ReportType = 'message' | 'user' | 'guild';
type FlowStep = 'selection' | 'email' | 'verification' | 'details' | 'complete';
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const VERIFICATION_CODE_REGEX = /^[A-Z0-9]{4}-[A-Z0-9]{4}$/;
const FLOW_TOTAL_STEPS = 4;
const INITIAL_FORM_VALUES = {
category: '',
reporterFullName: '',
reporterCountry: '',
reporterFluxerTag: '',
messageLink: '',
messageUserTag: '',
userId: '',
userTag: '',
guildId: '',
inviteCode: '',
additionalInfo: '',
};
type FormValues = typeof INITIAL_FORM_VALUES;
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;
};
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};
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,
});
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,
};
case 'GO_TO_VERIFICATION':
return {
...state,
flowStep: 'verification',
verificationCode: '',
ticket: null,
errorMessage: null,
};
case 'GO_TO_DETAILS':
return {
...state,
flowStep: 'details',
errorMessage: null,
};
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,
};
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,
};
default:
return state;
}
}
function getStepNumber(step: FlowStep): number | null {
switch (step) {
case 'selection':
return 1;
case 'email':
return 2;
case 'verification':
return 3;
case 'details':
return 4;
case 'complete':
return null;
}
}
function formatVerificationCodeInput(raw: string): string {
const cleaned = raw
.toUpperCase()
.replace(/[^A-Z0-9]/g, '')
.slice(0, 8);
if (cleaned.length <= 4) return cleaned;
return `${cleaned.slice(0, 4)}-${cleaned.slice(4)}`;
}
function normalizeLikelyUrl(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return '';
if (!/^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(trimmed)) {
return `https://${trimmed}`;
}
return trimmed;
}
function isValidHttpUrl(raw: string): boolean {
try {
const url = new URL(raw);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
type ValidationError = {path: string; message: string};
export const ReportPage = observer(() => {
const {t} = useLingui();
const authLayout = React.useContext(AuthLayoutContext);
useFluxerDocumentTitle(t`Report Illegal Content`);
React.useLayoutEffect(() => {
if (!authLayout) return;
authLayout.setShowLogoSide(false);
return () => authLayout.setShowLogoSide(true);
}, [authLayout]);
const [state, dispatch] = React.useReducer(reducer, undefined, createInitialState);
const reportTypeOptions = React.useMemo<ReadonlyArray<RadioOption<ReportType>>>(
() => [
{value: 'message', name: t`Report a Message`},
{value: 'user', name: t`Report a User Profile`},
{value: 'guild', name: t`Report a Community`},
],
const parseValidationErrors = React.useCallback(
(
error: unknown,
): {fieldErrors: Partial<Record<keyof FormValues, string>>; generalMessage: string | null} | null => {
if (error && typeof error === 'object' && 'body' in error && (error as {body?: unknown}).body) {
const body = (error as {body?: any}).body;
const pathMap: Record<string, keyof FormValues> = {
category: 'category',
reporter_full_legal_name: 'reporterFullName',
reporter_country_of_residence: 'reporterCountry',
reporter_fluxer_tag: 'reporterFluxerTag',
message_link: 'messageLink',
reported_user_tag: 'messageUserTag',
user_id: 'userId',
user_tag: 'userTag',
guild_id: 'guildId',
invite_code: 'inviteCode',
additional_info: 'additionalInfo',
};
if (body?.code === 'INVALID_FORM_BODY' && Array.isArray(body.errors)) {
const fieldErrors: Partial<Record<keyof FormValues, string>> = {};
const errors = body.errors as Array<ValidationError>;
for (const err of errors) {
const mapped = pathMap[err.path];
if (mapped) {
fieldErrors[mapped] = err.message;
}
}
const hasFieldErrors = Object.keys(fieldErrors).length > 0;
const generalMessage = hasFieldErrors
? null
: (errors[0]?.message ?? t`Something went wrong while submitting the report. Please try again.`);
return {fieldErrors, generalMessage};
}
if (typeof body?.message === 'string') {
return {fieldErrors: {}, generalMessage: body.message};
}
}
return null;
},
[t],
);
const reportTypeLabel = React.useMemo(() => {
const match = reportTypeOptions.find((o) => o.value === state.selectedType);
return match?.name ?? '';
}, [reportTypeOptions, state.selectedType]);
const reportTypeOptions = React.useMemo<ReadonlyArray<RadioOption<ReportType>>>(() => {
return REPORT_TYPE_OPTION_DESCRIPTORS.map((option) => ({
value: option.value,
name: t(option.name),
}));
}, [t]);
const messageCategoryOptions = React.useMemo<Array<SelectOption<string>>>(
() => [
{value: '', label: t`Select a category`},
{value: 'harassment', label: t`Harassment or Bullying`},
{value: 'hate_speech', label: t`Hate Speech`},
{value: 'violent_content', label: t`Violent or Graphic Content`},
{value: 'spam', label: t`Spam or Scam`},
{value: 'nsfw_violation', label: t`NSFW Policy Violation`},
{value: 'illegal_activity', label: t`Illegal Activity`},
{value: 'doxxing', label: t`Sharing Personal Information`},
{value: 'self_harm', label: t`Self-Harm or Suicide`},
{value: 'child_safety', label: t`Child Safety Concerns`},
{value: 'malicious_links', label: t`Malicious Links`},
{value: 'impersonation', label: t`Impersonation`},
{value: 'other', label: t`Other`},
],
[t],
);
const messageCategoryOptions = React.useMemo<Array<SelectOption<string>>>(() => {
return MESSAGE_CATEGORY_OPTIONS.map((option) => ({
value: option.value,
label: t(option.label),
}));
}, [t]);
const userCategoryOptions = React.useMemo<Array<SelectOption<string>>>(
() => [
{value: '', label: t`Select a category`},
{value: 'harassment', label: t`Harassment or Bullying`},
{value: 'hate_speech', label: t`Hate Speech`},
{value: 'spam_account', label: t`Spam Account`},
{value: 'impersonation', label: t`Impersonation`},
{value: 'underage_user', label: t`Underage User`},
{value: 'inappropriate_profile', label: t`Inappropriate Profile`},
{value: 'other', label: t`Other`},
],
[t],
);
const userCategoryOptions = React.useMemo<Array<SelectOption<string>>>(() => {
return USER_CATEGORY_OPTIONS.map((option) => ({
value: option.value,
label: t(option.label),
}));
}, [t]);
const guildCategoryOptions = React.useMemo<Array<SelectOption<string>>>(
() => [
{value: '', label: t`Select a category`},
{value: 'harassment', label: t`Harassment`},
{value: 'hate_speech', label: t`Hate Speech`},
{value: 'extremist_community', label: t`Extremist Community`},
{value: 'illegal_activity', label: t`Illegal Activity`},
{value: 'child_safety', label: t`Child Safety Concerns`},
{value: 'raid_coordination', label: t`Raid Coordination`},
{value: 'spam', label: t`Spam or Scam Community`},
{value: 'malware_distribution', label: t`Malware Distribution`},
{value: 'other', label: t`Other`},
],
[t],
);
const guildCategoryOptions = React.useMemo<Array<SelectOption<string>>>(() => {
return GUILD_CATEGORY_OPTIONS.map((option) => ({
value: option.value,
label: t(option.label),
}));
}, [t]);
const countryOptions = React.useMemo<Array<SelectOption<string>>>(
() => [
{value: '', label: t`Select a country`},
{value: 'AT', label: t`Austria`},
{value: 'BE', label: t`Belgium`},
{value: 'BG', label: t`Bulgaria`},
{value: 'HR', label: t`Croatia`},
{value: 'CY', label: t`Cyprus`},
{value: 'CZ', label: t`Czech Republic`},
{value: 'DK', label: t`Denmark`},
{value: 'EE', label: t`Estonia`},
{value: 'FI', label: t`Finland`},
{value: 'FR', label: t`France`},
{value: 'DE', label: t`Germany`},
{value: 'GR', label: t`Greece`},
{value: 'HU', label: t`Hungary`},
{value: 'IE', label: t`Ireland`},
{value: 'IT', label: t`Italy`},
{value: 'LV', label: t`Latvia`},
{value: 'LT', label: t`Lithuania`},
{value: 'LU', label: t`Luxembourg`},
{value: 'MT', label: t`Malta`},
{value: 'NL', label: t`Netherlands`},
{value: 'PL', label: t`Poland`},
{value: 'PT', label: t`Portugal`},
{value: 'RO', label: t`Romania`},
{value: 'SK', label: t`Slovakia`},
{value: 'SI', label: t`Slovenia`},
{value: 'ES', label: t`Spain`},
{value: 'SE', label: t`Sweden`},
],
[t],
);
const countryOptions = React.useMemo<Array<SelectOption<string>>>(() => {
return COUNTRY_OPTIONS.map((option) => ({
value: option.value,
label: t(option.label),
}));
}, [t]);
const categoryOptionsByType = React.useMemo(() => {
return {
@@ -349,7 +160,11 @@ export const ReportPage = observer(() => {
const categoryOptions = state.selectedType ? categoryOptionsByType[state.selectedType] : [];
const isBusy = state.isSendingCode || state.isVerifying || state.isSubmitting;
React.useEffect(() => {
if (state.resendCooldownSeconds <= 0) return;
const timer = window.setInterval(() => dispatch({type: 'TICK_RESEND_COOLDOWN'}), 1000);
return () => window.clearInterval(timer);
}, [state.resendCooldownSeconds, dispatch]);
React.useEffect(() => {
if (state.flowStep === 'selection') return;
@@ -399,6 +214,9 @@ export const ReportPage = observer(() => {
dispatch({type: 'SET_ERROR', message: null});
dispatch({type: 'SENDING_CODE', value: true});
if (state.flowStep === 'verification') {
dispatch({type: 'START_RESEND_COOLDOWN', seconds: 30});
}
try {
await HttpClient.post({
@@ -408,12 +226,19 @@ export const ReportPage = observer(() => {
dispatch({type: 'SET_EMAIL', email: normalizedEmail});
dispatch({type: 'GO_TO_VERIFICATION'});
if (state.flowStep === 'verification') {
ToastActionCreators.createToast({type: 'success', children: t`Code resent`});
}
} catch (_error) {
dispatch({type: 'SET_ERROR', message: t`Failed to send verification code. Please try again.`});
if (state.flowStep === 'verification') {
ToastActionCreators.createToast({type: 'error', children: t`Failed to resend code. Please try again.`});
}
} finally {
dispatch({type: 'SENDING_CODE', value: false});
}
}, [state.email, state.isSendingCode, state.isVerifying, state.isSubmitting, t]);
}, [state.email, state.isSendingCode, state.isVerifying, state.isSubmitting, state.flowStep, t]);
const verifyCode = React.useCallback(async () => {
if (state.isSendingCode || state.isVerifying || state.isSubmitting) return;
@@ -464,6 +289,8 @@ export const ReportPage = observer(() => {
return;
}
dispatch({type: 'CLEAR_FIELD_ERRORS'});
const reporterFullName = state.formValues.reporterFullName.trim();
const reporterCountry = state.formValues.reporterCountry;
const reporterFluxerTag = state.formValues.reporterFluxerTag.trim();
@@ -560,222 +387,17 @@ export const ReportPage = observer(() => {
dispatch({type: 'SUBMIT_SUCCESS', reportId: response.body.report_id});
} catch (_error) {
const parsed = parseValidationErrors(_error);
if (parsed) {
dispatch({type: 'SET_FIELD_ERRORS', errors: parsed.fieldErrors});
dispatch({type: 'SET_ERROR', message: parsed.generalMessage});
} else {
dispatch({type: 'SET_ERROR', message: t`Something went wrong while submitting the report. Please try again.`});
}
dispatch({type: 'SUBMITTING', value: false});
}
}, [state, t]);
const stepNumber = getStepNumber(state.flowStep);
const renderStepHeader = (title: React.ReactNode, description: React.ReactNode) => (
<>
{stepNumber !== null && (
<div className={styles.stepIndicator}>
<Trans>
Step {stepNumber} of {FLOW_TOTAL_STEPS}
</Trans>
</div>
)}
<h1 className={styles.title}>{title}</h1>
<p className={styles.description}>{description}</p>
</>
);
if (state.flowStep === 'complete' && state.successReportId) {
return (
<div className={styles.container}>
{renderStepHeader(
<Trans>Report Submitted</Trans>,
<Trans>Thank you for filing a report. We&apos;ll review it as soon as possible.</Trans>,
)}
<div className={styles.successBox}>
<div className={styles.successLabel}>
<Trans>Your submission ID</Trans>
</div>
<div className={styles.successValue}>{state.successReportId}</div>
</div>
<div className={styles.footer}>
<button type="button" className={styles.link} onClick={() => dispatch({type: 'RESET_ALL'})} disabled={isBusy}>
<Trans>Submit another report</Trans>
</button>
</div>
</div>
);
}
if (state.flowStep === 'selection') {
return (
<div className={styles.container}>
{renderStepHeader(
<Trans>Report Illegal Content</Trans>,
<Trans>
Use this form to report illegal content under the Digital Services Act (DSA). Select what you want to
report.
</Trans>,
)}
<div className={styles.form}>
<RadioGroup<ReportType>
options={reportTypeOptions}
value={state.selectedType}
onChange={onSelectType}
aria-label={t`Report Type`}
/>
</div>
</div>
);
}
if (state.flowStep === 'email') {
const normalizedEmail = state.email.trim();
const emailLooksValid = normalizedEmail.length > 0 && EMAIL_REGEX.test(normalizedEmail);
return (
<div className={styles.container}>
{renderStepHeader(
<Trans>Enter Your Email</Trans>,
<Trans>We need to verify your email address before you can submit a report.</Trans>,
)}
{state.selectedType && (
<div className={styles.metaLine}>
<Trans>Reporting:</Trans> <span className={styles.metaValue}>{reportTypeLabel}</span>
</div>
)}
{state.errorMessage && (
<div className={styles.errorBox} role="alert" aria-live="polite">
{state.errorMessage}
</div>
)}
<form
className={styles.form}
onSubmit={(e) => {
e.preventDefault();
void sendVerificationCode();
}}
>
<Input
label={t`Email Address`}
type="email"
value={state.email}
onChange={(e) => dispatch({type: 'SET_EMAIL', email: e.target.value})}
placeholder="you@example.com"
autoComplete="email"
/>
<Button
fitContainer
type="submit"
disabled={!emailLooksValid || state.isSendingCode}
submitting={state.isSendingCode}
>
<Trans>Send Verification Code</Trans>
</Button>
</form>
<div className={styles.footer}>
<button
type="button"
className={styles.link}
onClick={() => dispatch({type: 'GO_TO_SELECTION'})}
disabled={isBusy}
>
<Trans>Start over</Trans>
</button>
</div>
</div>
);
}
if (state.flowStep === 'verification') {
const codeForValidation = state.verificationCode.trim().toUpperCase();
const codeLooksValid = VERIFICATION_CODE_REGEX.test(codeForValidation);
return (
<div className={styles.container}>
{renderStepHeader(
<Trans>Enter Verification Code</Trans>,
<Trans>We sent a verification code to {state.email}.</Trans>,
)}
{state.errorMessage && (
<div className={styles.errorBox} role="alert" aria-live="polite">
{state.errorMessage}
</div>
)}
<form
className={styles.form}
onSubmit={(e) => {
e.preventDefault();
void verifyCode();
}}
>
<Input
label={t`Verification Code`}
type="text"
value={state.verificationCode}
onChange={(e) =>
dispatch({type: 'SET_VERIFICATION_CODE', code: formatVerificationCodeInput(e.target.value)})
}
placeholder="ABCD-1234"
autoComplete="one-time-code"
footer={
<span className={styles.helperText}>
<Trans>Letters and numbers only. You can paste the codeformatting is automatic.</Trans>
</span>
}
/>
<Button
fitContainer
type="submit"
disabled={!codeLooksValid || state.isVerifying}
submitting={state.isVerifying}
>
<Trans>Verify Code</Trans>
</Button>
</form>
<div className={styles.footer}>
<div className={styles.footerRow}>
<button
type="button"
className={styles.link}
onClick={() => dispatch({type: 'GO_TO_EMAIL'})}
disabled={isBusy}
>
<Trans>Change email</Trans>
</button>
<button
type="button"
className={styles.link}
onClick={() => void sendVerificationCode()}
disabled={state.isSendingCode || state.isVerifying || state.isSubmitting}
>
<Trans>Resend code</Trans>
</button>
</div>
<button
type="button"
className={styles.link}
onClick={() => dispatch({type: 'GO_TO_SELECTION'})}
disabled={isBusy}
>
<Trans>Start over</Trans>
</button>
</div>
</div>
);
}
if (state.flowStep === 'details' && state.selectedType) {
const reporterFullName = state.formValues.reporterFullName.trim();
const reporterCountry = state.formValues.reporterCountry;
const category = state.formValues.category;
@@ -796,177 +418,118 @@ export const ReportPage = observer(() => {
userTargetOk &&
guildTargetOk;
const handleBreadcrumbSelect = (step: FlowStep) => {
switch (step) {
case 'selection':
dispatch({type: 'GO_TO_SELECTION'});
break;
case 'email':
dispatch({type: 'GO_TO_EMAIL'});
break;
case 'verification':
dispatch({type: 'GO_TO_VERIFICATION'});
break;
case 'details':
dispatch({type: 'GO_TO_DETAILS'});
break;
default:
break;
}
};
const renderStep = () => {
switch (state.flowStep) {
case 'selection':
return (
<div className={styles.container}>
{renderStepHeader(<Trans>Report Details</Trans>, <Trans>Please provide the details of your report.</Trans>)}
<div className={styles.metaLine}>
<Trans>Reporting:</Trans> <span className={styles.metaValue}>{reportTypeLabel}</span>
<span className={styles.metaSpacer} aria-hidden="true">
</span>
<span className={styles.metaValue}>
<Trans>Email verified</Trans>
</span>
</div>
{state.errorMessage && (
<div className={styles.errorBox} role="alert" aria-live="polite">
{state.errorMessage}
</div>
)}
<form
className={styles.form}
onSubmit={(e) => {
e.preventDefault();
void handleSubmit();
}}
>
<Select<string>
label={t`Violation Category`}
value={state.formValues.category}
options={categoryOptions}
onChange={(value) => dispatch({type: 'SET_FORM_FIELD', field: 'category', value})}
isSearchable={false}
<ReportStepSelection
reportTypeOptions={reportTypeOptions}
selectedType={state.selectedType}
onSelect={onSelectType}
/>
);
{state.selectedType === 'message' && (
<>
<Input
label={t`Message Link`}
type="url"
value={state.formValues.messageLink}
onChange={(e) => dispatch({type: 'SET_FORM_FIELD', field: 'messageLink', value: e.target.value})}
placeholder="https://fluxer.app/channels/..."
autoComplete="off"
footer={
!state.formValues.messageLink.trim() ? undefined : !messageLinkOk ? (
<span className={styles.helperText}>
<Trans>That doesn&apos;t look like a valid URL.</Trans>
</span>
) : undefined
case 'email':
return (
<ReportStepEmail
email={state.email}
errorMessage={state.errorMessage}
isSending={state.isSendingCode}
onEmailChange={(value) => dispatch({type: 'SET_EMAIL', email: value})}
onSubmit={() => void sendVerificationCode()}
onStartOver={() => dispatch({type: 'GO_TO_SELECTION'})}
/>
);
case 'verification':
return (
<ReportStepVerification
email={state.email}
verificationCode={state.verificationCode}
errorMessage={state.errorMessage}
isVerifying={state.isVerifying}
isResending={state.isSendingCode}
resendCooldownSeconds={state.resendCooldownSeconds}
onChangeEmail={() => dispatch({type: 'GO_TO_EMAIL'})}
onResend={() => void sendVerificationCode()}
onVerify={() => void verifyCode()}
onCodeChange={(value) =>
dispatch({type: 'SET_VERIFICATION_CODE', code: formatVerificationCodeInput(value)})
}
onStartOver={() => dispatch({type: 'GO_TO_SELECTION'})}
/>
<Input
label={t`Reported User Tag (optional)`}
type="text"
value={state.formValues.messageUserTag}
onChange={(e) => dispatch({type: 'SET_FORM_FIELD', field: 'messageUserTag', value: e.target.value})}
placeholder="username#1234"
autoComplete="off"
/>
</>
)}
);
{state.selectedType === 'user' && (
<>
<Input
label={t`User ID (optional)`}
type="text"
value={state.formValues.userId}
onChange={(e) => dispatch({type: 'SET_FORM_FIELD', field: 'userId', value: e.target.value})}
placeholder="123456789012345678"
autoComplete="off"
case 'details':
return (
<ReportStepDetails
selectedType={state.selectedType as ReportType}
formValues={state.formValues}
categoryOptions={categoryOptions}
countryOptions={countryOptions}
fieldErrors={state.fieldErrors}
errorMessage={state.errorMessage}
canSubmit={canSubmit}
isSubmitting={state.isSubmitting}
onFieldChange={(field, value) => dispatch({type: 'SET_FORM_FIELD', field, value})}
onSubmit={() => void handleSubmit()}
onStartOver={() => dispatch({type: 'RESET_ALL'})}
onBack={() => dispatch({type: 'GO_TO_VERIFICATION'})}
messageLinkOk={messageLinkOk}
userTargetOk={userTargetOk}
guildTargetOk={guildTargetOk}
/>
<Input
label={t`User Tag (optional)`}
type="text"
value={state.formValues.userTag}
onChange={(e) => dispatch({type: 'SET_FORM_FIELD', field: 'userTag', value: e.target.value})}
placeholder="username#1234"
autoComplete="off"
footer={
userTargetOk ? undefined : (
<span className={styles.helperText}>
<Trans>Provide at least a user ID or a user tag.</Trans>
</span>
)
);
case 'complete':
return state.successReportId ? <ReportStepComplete onStartOver={() => dispatch({type: 'RESET_ALL'})} /> : null;
default:
return null;
}
/>
</>
)}
};
{state.selectedType === 'guild' && (
<>
<Input
label={t`Guild (Community) ID`}
type="text"
value={state.formValues.guildId}
onChange={(e) => dispatch({type: 'SET_FORM_FIELD', field: 'guildId', value: e.target.value})}
placeholder="123456789012345678"
autoComplete="off"
footer={
guildTargetOk ? undefined : (
<span className={styles.helperText}>
<Trans>Guild ID is required.</Trans>
</span>
)
}
const breadcrumbs =
state.flowStep === 'complete' ? null : (
<ReportBreadcrumbs
current={state.flowStep}
hasSelection={Boolean(state.selectedType)}
hasEmail={Boolean(state.email.trim())}
hasTicket={Boolean(state.ticket)}
onSelect={handleBreadcrumbSelect}
/>
<Input
label={t`Invite Code (optional)`}
type="text"
value={state.formValues.inviteCode}
onChange={(e) => dispatch({type: 'SET_FORM_FIELD', field: 'inviteCode', value: e.target.value})}
placeholder="abcDEF12"
autoComplete="off"
/>
</>
)}
);
<Input
label={t`Full Legal Name`}
type="text"
value={state.formValues.reporterFullName}
onChange={(e) => dispatch({type: 'SET_FORM_FIELD', field: 'reporterFullName', value: e.target.value})}
placeholder={t`First and last name`}
autoComplete="name"
/>
<Select<string>
label={t`Country of Residence`}
value={state.formValues.reporterCountry}
options={countryOptions}
onChange={(value) => dispatch({type: 'SET_FORM_FIELD', field: 'reporterCountry', value})}
/>
<Input
label={t`Your FluxerTag (optional)`}
type="text"
value={state.formValues.reporterFluxerTag}
onChange={(e) => dispatch({type: 'SET_FORM_FIELD', field: 'reporterFluxerTag', value: e.target.value})}
placeholder="username#1234"
/>
<Textarea
label={t`Additional Comments (optional)`}
value={state.formValues.additionalInfo}
onChange={(e) => dispatch({type: 'SET_FORM_FIELD', field: 'additionalInfo', value: e.target.value})}
placeholder={t`Describe what makes the content illegal`}
maxLength={1000}
minRows={3}
maxRows={6}
/>
<Button
fitContainer
type="submit"
disabled={!canSubmit || state.isSubmitting}
submitting={state.isSubmitting}
>
<Trans>Submit DSA Report</Trans>
</Button>
</form>
<div className={styles.footer}>
<button type="button" className={styles.link} onClick={() => dispatch({type: 'RESET_ALL'})} disabled={isBusy}>
<Trans>Start over</Trans>
</button>
</div>
const breadcrumbShell =
state.flowStep === 'complete' ? null : (
<div className={styles.breadcrumbShell}>
{breadcrumbs ?? <span className={styles.breadcrumbPlaceholder} aria-hidden="true" />}
</div>
);
}
return null;
return (
<div className={styles.page}>
{breadcrumbShell}
<div className={styles.mainColumn}>{renderStep()}</div>
</div>
);
});

View 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;

View File

@@ -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;

View 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;

View 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;

View File

@@ -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;

View File

@@ -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;

View 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`},
];

View 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;
}
}

View 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