[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}" echo "Deploying commit ${sha}"
printf 'DEPLOY_SHA=%s\n' "$sha" >> "$GITHUB_ENV" printf 'DEPLOY_SHA=%s\n' "$sha" >> "$GITHUB_ENV"
- name: Set build timestamp
run: echo "BUILD_TIMESTAMP=$(date -u +%s)" >> "$GITHUB_ENV"
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -89,6 +92,8 @@ jobs:
platforms: linux/amd64 platforms: linux/amd64
cache-from: type=gha,scope=${{ env.CACHE_SCOPE }} cache-from: type=gha,scope=${{ env.CACHE_SCOPE }}
cache-to: type=gha,mode=max,scope=${{ env.CACHE_SCOPE }} cache-to: type=gha,mode=max,scope=${{ env.CACHE_SCOPE }}
build-args: |
BUILD_TIMESTAMP=${{ env.BUILD_TIMESTAMP }}
env: env:
DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false DOCKER_BUILD_RECORD_UPLOAD: false

View File

@@ -74,6 +74,9 @@ jobs:
echo "Deploying commit ${sha}" echo "Deploying commit ${sha}"
printf 'DEPLOY_SHA=%s\n' "$sha" >> "$GITHUB_ENV" printf 'DEPLOY_SHA=%s\n' "$sha" >> "$GITHUB_ENV"
- name: Set build timestamp
run: echo "BUILD_TIMESTAMP=$(date -u +%s)" >> "$GITHUB_ENV"
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -93,7 +96,8 @@ jobs:
platforms: linux/amd64 platforms: linux/amd64
cache-from: type=gha,scope=${{ env.CACHE_SCOPE }} cache-from: type=gha,scope=${{ env.CACHE_SCOPE }}
cache-to: type=gha,mode=max,scope=${{ env.CACHE_SCOPE }} cache-to: type=gha,mode=max,scope=${{ env.CACHE_SCOPE }}
build-args: BUILD_TIMESTAMP=${{ github.run_id }} build-args: |
BUILD_TIMESTAMP=${{ env.BUILD_TIMESTAMP }}
env: env:
DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false DOCKER_BUILD_RECORD_UPLOAD: false

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] > [!NOTE]
> Docs are coming very soon! With your help and [donations](https://fluxer.app/donate), the self-hosting and documentation story will get a lot better. > Docs are coming very soon! With your help and [donations](https://fluxer.app/donate), the self-hosting and documentation story will get a lot better.

View File

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

View File

@@ -100,12 +100,24 @@ pub fn page_with_refresh(
[a.attribute("lang", "en"), a.attribute("data-base-path", ctx.base_path)], [a.attribute("lang", "en"), a.attribute("data-base-path", ctx.base_path)],
[ [
build_head_with_refresh(title, ctx, auto_refresh), build_head_with_refresh(title, ctx, auto_refresh),
h.body([a.class("min-h-screen bg-neutral-50 flex")], [ h.body([a.class("min-h-screen bg-neutral-50 overflow-hidden")], [
h.div([a.class("flex h-screen")], [
sidebar(ctx, active_page), 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), header(ctx, session, current_admin),
h.main([a.class("flex-1 p-8")], [ h.main([a.class("flex-1 p-4 sm:p-6 lg:p-8")], [
h.div([a.class("max-w-7xl mx-auto")], [ h.div([a.class("w-full max-w-7xl mx-auto")], [
case flash_data { case flash_data {
option.Some(_) -> option.Some(_) ->
h.div([a.class("mb-6")], [flash.view(flash_data)]) h.div([a.class("mb-6")], [flash.view(flash_data)])
@@ -114,7 +126,10 @@ pub fn page_with_refresh(
content, content,
]), ]),
]), ]),
],
),
]), ]),
sidebar_interaction_script(),
]), ]),
], ],
) )
@@ -123,18 +138,37 @@ pub fn page_with_refresh(
fn sidebar(ctx: Context, active_page: String) { fn sidebar(ctx: Context, active_page: String) {
h.div( h.div(
[ [
a.attribute("data-sidebar", ""),
a.class( a.class(
"w-64 bg-neutral-900 text-white flex flex-col h-screen fixed left-0 top-0", "fixed inset-y-0 left-0 z-40 w-64 h-screen bg-neutral-900 text-white flex flex-col transform -translate-x-full transition-transform duration-200 ease-in-out lg:translate-x-0 lg:static lg:inset-auto shadow-xl lg:shadow-none",
),
],
[
h.div(
[
a.class(
"p-6 border-b border-neutral-800 flex items-center justify-between gap-3",
), ),
], ],
[ [
h.div([a.class("p-6 border-b border-neutral-800")], [
h.a([href(ctx, "/users")], [ h.a([href(ctx, "/users")], [
h.h1([a.class("text-base font-semibold")], [ h.h1([a.class("text-base font-semibold")], [
element.text("Fluxer Admin"), element.text("Fluxer Admin"),
]), ]),
]), ]),
]), h.button(
[
a.type_("button"),
a.attribute("data-sidebar-close", ""),
a.class(
"lg:hidden inline-flex items-center justify-center p-2 rounded-md text-neutral-200 hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-white/40",
),
a.attribute("aria-label", "Close sidebar"),
],
[element.text("Close")],
),
],
),
h.nav( h.nav(
[ [
a.class("flex-1 overflow-y-auto p-4 space-y-1 sidebar-scrollbar"), a.class("flex-1 overflow-y-auto p-4 space-y-1 sidebar-scrollbar"),
@@ -304,11 +338,68 @@ fn header(
h.header( h.header(
[ [
a.class( a.class(
"bg-white border-b border-neutral-200 px-8 py-4 flex items-center justify-between", "bg-white border-b border-neutral-200 px-4 sm:px-6 lg:px-8 py-4 flex items-center justify-between gap-4 sticky top-0 z-10",
), ),
], ],
[ [
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), render_user_info(ctx, session, current_admin),
]),
h.a( h.a(
[ [
href(ctx, "/logout"), href(ctx, "/logout"),
@@ -390,3 +481,58 @@ fn render_avatar(
a.class("w-10 h-10 rounded-full"), a.class("w-10 h-10 rounded-full"),
]) ])
} }
fn sidebar_interaction_script() {
h.script(
[a.attribute("defer", "defer")],
"
(function() {
const sidebar = document.querySelector('[data-sidebar]');
const overlay = document.querySelector('[data-sidebar-overlay]');
const toggles = document.querySelectorAll('[data-sidebar-toggle]');
const closes = document.querySelectorAll('[data-sidebar-close]');
if (!sidebar || !overlay) return;
const open = () => {
sidebar.classList.remove('-translate-x-full');
overlay.classList.remove('hidden');
document.body.classList.add('overflow-hidden');
};
const close = () => {
sidebar.classList.add('-translate-x-full');
overlay.classList.add('hidden');
document.body.classList.remove('overflow-hidden');
};
toggles.forEach((btn) => btn.addEventListener('click', () => {
if (sidebar.classList.contains('-translate-x-full')) {
open();
} else {
close();
}
}));
closes.forEach((btn) => btn.addEventListener('click', close));
overlay.addEventListener('click', close);
window.addEventListener('keydown', (event) => {
if (event.key === 'Escape') close();
});
const syncForDesktop = () => {
if (window.innerWidth >= 1024) {
overlay.classList.add('hidden');
document.body.classList.remove('overflow-hidden');
sidebar.classList.remove('-translate-x-full');
} else {
sidebar.classList.add('-translate-x-full');
}
};
window.addEventListener('resize', syncForDesktop);
syncForDesktop();
})();
",
)
}

View File

@@ -28,7 +28,7 @@ pub type Tab {
pub fn render_tabs(ctx: Context, tabs: List(Tab)) -> element.Element(a) { pub fn render_tabs(ctx: Context, tabs: List(Tab)) -> element.Element(a) {
h.div([a.class("border-b border-neutral-200 mb-6")], [ h.div([a.class("border-b border-neutral-200 mb-6")], [
h.nav( h.nav(
[a.class("flex gap-6")], [a.class("flex gap-6 overflow-x-auto no-scrollbar -mb-px px-1")],
list.map(tabs, fn(tab) { render_tab(ctx, tab) }), list.map(tabs, fn(tab) { render_tab(ctx, tab) }),
), ),
]) ])
@@ -36,9 +36,10 @@ pub fn render_tabs(ctx: Context, tabs: List(Tab)) -> element.Element(a) {
fn render_tab(ctx: Context, tab: Tab) -> element.Element(a) { fn render_tab(ctx: Context, tab: Tab) -> element.Element(a) {
let class_active = case tab.active { let class_active = case tab.active {
True -> "border-b-2 border-neutral-900 text-neutral-900 text-sm pb-3" True ->
"border-b-2 border-neutral-900 text-neutral-900 text-sm pb-3 whitespace-nowrap"
False -> False ->
"border-b-2 border-transparent text-neutral-600 hover:text-neutral-900 hover:border-neutral-300 text-sm pb-3 transition-colors" "border-b-2 border-transparent text-neutral-600 hover:text-neutral-900 hover:border-neutral-300 text-sm pb-3 transition-colors whitespace-nowrap"
} }
h.a([href(ctx, tab.path), a.class(class_active)], [element.text(tab.label)]) h.a([href(ctx, tab.path), a.class(class_active)], [element.text(tab.label)])

View File

@@ -23,7 +23,7 @@ import lustre/attribute as a
import lustre/element import lustre/element
import lustre/element/html as h import lustre/element/html as h
pub const table_container_class = "bg-white border border-neutral-200 rounded-lg overflow-hidden" pub const table_container_class = "bg-white border border-neutral-200 rounded-lg overflow-hidden overflow-x-auto"
pub const table_header_cell_class = "px-6 py-3 text-left text-xs text-neutral-600 uppercase tracking-wider" pub const table_header_cell_class = "px-6 py-3 text-left text-xs text-neutral-600 uppercase tracking-wider"
@@ -469,7 +469,10 @@ pub fn flex_row(
pub fn flex_row_between( pub fn flex_row_between(
children: List(element.Element(a)), children: List(element.Element(a)),
) -> element.Element(a) { ) -> element.Element(a) {
h.div([a.class("mb-6 flex items-center justify-between")], children) h.div(
[a.class("mb-6 flex flex-wrap items-center justify-between gap-3")],
children,
)
} }
pub fn stack( pub fn stack(
@@ -570,6 +573,7 @@ pub fn data_table(
h.div( h.div(
[a.class("bg-white border border-neutral-200 rounded-lg overflow-hidden")], [a.class("bg-white border border-neutral-200 rounded-lg overflow-hidden")],
[ [
h.div([a.class("overflow-x-auto")], [
h.table([a.class("min-w-full divide-y divide-neutral-200")], [ h.table([a.class("min-w-full divide-y divide-neutral-200")], [
h.thead([a.class("bg-neutral-50")], [ h.thead([a.class("bg-neutral-50")], [
h.tr( 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), h.input(checkbox_attrs),
element.element( element.element(
"svg", "svg",
@@ -637,7 +642,7 @@ pub fn custom_checkbox(
a.attribute("xmlns", "http://www.w3.org/2000/svg"), a.attribute("xmlns", "http://www.w3.org/2000/svg"),
a.attribute("viewBox", "0 0 256 256"), a.attribute("viewBox", "0 0 256 256"),
a.class( a.class(
"w-5 h-5 bg-white border-2 border-neutral-300 rounded p-0.5 text-white peer-checked:bg-neutral-900 peer-checked:border-neutral-900 transition-colors", "w-5 h-5 bg-white border-2 border-neutral-300 rounded p-0.5 text-white peer-checked:bg-neutral-900 peer-checked:border-neutral-900 transition-colors flex-shrink-0",
), ),
], ],
[ [
@@ -655,8 +660,15 @@ pub fn custom_checkbox(
), ),
], ],
), ),
h.span([a.class("text-sm text-neutral-900 group-hover:text-neutral-700")], [ h.div([a.class("flex-1 min-w-0")], [
element.text(label), h.span(
[
a.class(
"text-sm text-neutral-900 group-hover:text-neutral-700 leading-snug truncate",
),
],
[element.text(label)],
),
]), ]),
]) ])
} }

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_user_cancel_bulk_message_deletion = "user:cancel:bulk_message_deletion"
pub const acl_pending_verification_view = "pending_verification:view"
pub const acl_pending_verification_review = "pending_verification:review"
pub const acl_beta_codes_generate = "beta_codes:generate" pub const acl_beta_codes_generate = "beta_codes:generate"
pub const acl_gift_codes_generate = "gift_codes:generate" pub const acl_gift_codes_generate = "gift_codes:generate"
@@ -506,6 +510,8 @@ pub fn get_all_acls() -> List(String) {
acl_user_disable_suspicious, acl_user_disable_suspicious,
acl_user_delete, acl_user_delete,
acl_user_cancel_bulk_message_deletion, acl_user_cancel_bulk_message_deletion,
acl_pending_verification_view,
acl_pending_verification_review,
acl_beta_codes_generate, acl_beta_codes_generate,
acl_gift_codes_generate, acl_gift_codes_generate,
acl_guild_lookup, acl_guild_lookup,

View File

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

View File

@@ -112,7 +112,7 @@ pub fn view(
fn render_search_form(ctx: Context, query: option.Option(String)) { fn render_search_form(ctx: Context, query: option.Option(String)) {
ui.card(ui.PaddingSmall, [ ui.card(ui.PaddingSmall, [
h.form([a.method("get"), a.class("flex flex-col gap-4")], [ h.form([a.method("get"), a.class("flex flex-col gap-4")], [
h.div([a.class("flex gap-2")], [ h.div([a.class("flex flex-col sm:flex-row gap-2")], [
h.input([ h.input([
a.type_("text"), a.type_("text"),
a.name("q"), a.name("q"),
@@ -123,12 +123,12 @@ fn render_search_form(ctx: Context, query: option.Option(String)) {
), ),
a.attribute("autocomplete", "off"), a.attribute("autocomplete", "off"),
]), ]),
ui.button_primary("Search", "submit", []), ui.button_primary("Search", "submit", [a.class("w-full sm:w-auto")]),
h.a( h.a(
[ [
href(ctx, "/guilds"), href(ctx, "/guilds"),
a.class( a.class(
"px-4 py-2 bg-white text-neutral-700 border border-neutral-300 rounded-lg text-sm font-medium hover:bg-neutral-50 transition-colors", "px-4 py-2 bg-white text-neutral-700 border border-neutral-300 rounded-lg text-sm font-medium hover:bg-neutral-50 transition-colors w-full sm:w-auto text-center",
), ),
], ],
[element.text("Clear")], [element.text("Clear")],
@@ -159,7 +159,7 @@ fn render_guild_card(ctx: Context, guild: guilds.GuildSearchResult) {
], ],
[ [
h.div([a.class("p-5")], [ h.div([a.class("p-5")], [
h.div([a.class("flex items-center gap-4")], [ h.div([a.class("flex flex-col sm:flex-row sm:items-center gap-4")], [
case case
avatar.get_guild_icon_url( avatar.get_guild_icon_url(
ctx.media_endpoint, ctx.media_endpoint,
@@ -169,15 +169,28 @@ fn render_guild_card(ctx: Context, guild: guilds.GuildSearchResult) {
) )
{ {
option.Some(icon_url) -> option.Some(icon_url) ->
h.div([a.class("flex-shrink-0")], [ h.div(
[
a.class(
"flex-shrink-0 flex items-center sm:block justify-center",
),
],
[
h.img([ h.img([
a.src(icon_url), a.src(icon_url),
a.alt(guild.name), a.alt(guild.name),
a.class("w-16 h-16 rounded-full"), a.class("w-16 h-16 rounded-full"),
]), ]),
]) ],
)
option.None -> 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( h.div(
[ [
a.class( a.class(
@@ -186,10 +199,11 @@ fn render_guild_card(ctx: Context, guild: guilds.GuildSearchResult) {
], ],
[element.text(avatar.get_initials_from_name(guild.name))], [element.text(avatar.get_initials_from_name(guild.name))],
), ),
]) ],
)
}, },
h.div([a.class("flex-1 min-w-0")], [ h.div([a.class("flex-1 min-w-0")], [
h.div([a.class("flex items-center gap-2 mb-2")], [ h.div([a.class("flex items-center gap-2 mb-2 flex-wrap")], [
h.h2([a.class("text-base font-medium text-neutral-900")], [ h.h2([a.class("text-base font-medium text-neutral-900")], [
element.text(guild.name), element.text(guild.name),
]), ]),
@@ -207,7 +221,7 @@ fn render_guild_card(ctx: Context, guild: guilds.GuildSearchResult) {
}, },
]), ]),
h.div([a.class("space-y-0.5")], [ h.div([a.class("space-y-0.5")], [
h.div([a.class("text-sm text-neutral-600")], [ h.div([a.class("text-sm text-neutral-600 break-all")], [
element.text("ID: " <> guild.id), element.text("ID: " <> guild.id),
]), ]),
h.div([a.class("text-sm text-neutral-600")], [ h.div([a.class("text-sm text-neutral-600")], [

View File

@@ -15,18 +15,16 @@
//// You should have received a copy of the GNU Affero General Public License //// You should have received a copy of the GNU Affero General Public License
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>. //// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
import fluxer_admin/acl
import fluxer_admin/api/common import fluxer_admin/api/common
import fluxer_admin/api/verifications import fluxer_admin/api/verifications
import fluxer_admin/avatar import fluxer_admin/avatar
import fluxer_admin/components/flash import fluxer_admin/components/flash
import fluxer_admin/components/layout import fluxer_admin/components/layout
import fluxer_admin/components/review_deck
import fluxer_admin/components/review_hintbar
import fluxer_admin/components/ui import fluxer_admin/components/ui
import fluxer_admin/constants
import fluxer_admin/user import fluxer_admin/user
import fluxer_admin/web.{ import fluxer_admin/web.{type Context, type Session, action, href}
type Context, type Session, action, href, prepend_base_path,
}
import gleam/int import gleam/int
import gleam/list import gleam/list
import gleam/option import gleam/option
@@ -47,6 +45,60 @@ const suspicious_user_agent_keywords = [
"go-http-client", "go-http-client",
] ]
fn selection_toolbar() -> element.Element(a) {
h.div(
[
a.class(
"mt-4 flex items-center justify-between gap-3 bg-neutral-50 border border-neutral-200 rounded-lg px-3 py-2",
),
a.attribute("data-selection-toolbar", "true"),
],
[
h.div([a.class("flex items-center gap-3")], [
h.input([
a.type_("checkbox"),
a.attribute("data-select-all", "true"),
a.class("h-4 w-4 rounded border-neutral-300"),
]),
h.span([a.class("text-sm text-neutral-700")], [
element.text("Select all visible"),
]),
]),
h.div([a.class("flex items-center gap-3 flex-wrap justify-end")], [
h.span(
[
a.attribute("data-selected-count", "true"),
a.class("text-sm text-neutral-600"),
],
[element.text("0 selected")],
),
h.div([a.class("flex items-center gap-2")], [
h.button(
[
a.attribute("data-bulk-action", "reject"),
a.class(
"px-3 py-1.5 bg-red-600 text-white rounded-lg text-sm disabled:opacity-40 disabled:cursor-not-allowed",
),
a.disabled(True),
],
[element.text("Reject selected")],
),
h.button(
[
a.attribute("data-bulk-action", "approve"),
a.class(
"px-3 py-1.5 bg-green-600 text-white rounded-lg text-sm disabled:opacity-40 disabled:cursor-not-allowed",
),
a.disabled(True),
],
[element.text("Approve selected")],
),
]),
]),
],
)
}
pub fn view( pub fn view(
ctx: Context, ctx: Context,
session: Session, session: Session,
@@ -55,6 +107,12 @@ pub fn view(
) -> Response { ) -> Response {
let limit = 50 let limit = 50
let result = verifications.list_pending_verifications(ctx, session, limit) let result = verifications.list_pending_verifications(ctx, session, limit)
let admin_acls = case current_admin {
option.Some(admin) -> admin.acls
option.None -> []
}
let can_review =
acl.has_permission(admin_acls, constants.acl_pending_verification_review)
let content = case result { let content = case result {
Ok(response) -> { Ok(response) -> {
@@ -75,72 +133,31 @@ pub fn view(
h.span( h.span(
[ [
a.class("body-sm text-neutral-600"), a.class("body-sm text-neutral-600"),
a.attribute("data-review-progress", ""), a.attribute("data-remaining-total", "true"),
],
[
element.text(int.to_string(count) <> " remaining"),
], ],
[element.text(int.to_string(count) <> " remaining")],
) )
}, },
]), ]),
]), ]),
case can_review {
True -> selection_toolbar()
False -> element.none()
},
case list.is_empty(response.pending_verifications) { case list.is_empty(response.pending_verifications) {
True -> empty_state() True -> empty_state()
False -> False ->
h.div( h.div([a.class("mt-4")], [
[a.class("mt-4")],
list.append(
[review_deck.styles()],
list.append(review_deck.script_tags(), [
h.div( h.div(
[ [
a.attribute("data-review-deck", "true"), a.class("grid gap-4 md:grid-cols-2 xl:grid-cols-3"),
a.attribute( a.attribute("data-select-grid", "true"),
"data-fragment-base",
prepend_base_path(
ctx,
"/pending-verifications/fragment",
),
),
a.attribute("data-next-page", "2"),
a.attribute("data-can-paginate", "true"),
a.attribute("data-prefetch-when-remaining", "6"),
a.attribute(
"data-empty-url",
prepend_base_path(ctx, "/pending-verifications"),
),
a.tabindex(0),
], ],
[
h.div(
[a.class("max-w-2xl mx-auto")],
list.map(response.pending_verifications, fn(pv) { 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, session,
current_admin, current_admin,
flash_data, flash_data,
content, h.div([], [content, pending_verifications_script()]),
) )
wisp.html_response(element.to_document_string(html), 200) wisp.html_response(element.to_document_string(html), 200)
} }
pub fn view_fragment(ctx: Context, session: Session, page: Int) -> Response { pub fn view_fragment(
ctx: Context,
session: Session,
page: Int,
can_review: Bool,
) -> Response {
let limit = 50 let limit = 50
let _offset = page * limit let _offset = page * limit
let result = verifications.list_pending_verifications(ctx, session, limit) let result = verifications.list_pending_verifications(ctx, session, limit)
@@ -170,19 +192,17 @@ pub fn view_fragment(ctx: Context, session: Session, page: Int) -> Response {
Ok(response) -> { Ok(response) -> {
let fragment = let fragment =
h.div( h.div(
[ [a.class("grid gap-4 md:grid-cols-2 xl:grid-cols-3")],
a.attribute("data-review-fragment", "true"),
a.attribute("data-page", int.to_string(page)),
],
list.map(response.pending_verifications, fn(pv) { list.map(response.pending_verifications, fn(pv) {
render_pending_verification_card(ctx, pv) render_pending_verification_card(ctx, pv, can_review)
}), }),
) )
wisp.html_response(element.to_document_string(fragment), 200) wisp.html_response(element.to_document_string(fragment), 200)
} }
Error(_) -> { Error(_) -> {
let empty = h.div([a.attribute("data-review-fragment", "true")], []) let empty =
h.div([a.class("grid gap-4 md:grid-cols-2 xl:grid-cols-3")], [])
wisp.html_response(element.to_document_string(empty), 200) wisp.html_response(element.to_document_string(empty), 200)
} }
@@ -198,6 +218,12 @@ pub fn view_single(
) -> Response { ) -> Response {
let limit = 50 let limit = 50
let result = verifications.list_pending_verifications(ctx, session, limit) let result = verifications.list_pending_verifications(ctx, session, limit)
let admin_acls = case current_admin {
option.Some(admin) -> admin.acls
option.None -> []
}
let can_review =
acl.has_permission(admin_acls, constants.acl_pending_verification_review)
let content = case result { let content = case result {
Ok(response) -> { Ok(response) -> {
@@ -223,46 +249,14 @@ pub fn view_single(
[element.text("Back to all")], [element.text("Back to all")],
), ),
]), ]),
h.div(
[a.class("mt-4")],
list.append(
[review_deck.styles()],
list.append(review_deck.script_tags(), [
h.div( h.div(
[ [
a.attribute("data-review-deck", "true"), a.class("mt-4 max-w-3xl"),
a.attribute( a.attribute("data-select-grid", "true"),
"data-empty-url",
prepend_base_path(ctx, "/pending-verifications"),
),
a.tabindex(0),
], ],
[ [
h.div([a.class("max-w-2xl mx-auto")], [ render_pending_verification_card(ctx, pv, can_review),
render_pending_verification_card(ctx, pv),
]),
h.div(
[
a.attribute("data-review-progress", "true"),
a.class("text-center mt-4 body-sm text-neutral-600"),
], ],
[
element.text("1 remaining"),
],
),
review_hintbar.view(
"",
"Reject",
"",
"Approve",
"Esc",
"Exit",
option.Some("Swipe cards on touch devices"),
),
],
),
]),
),
), ),
], ],
) )
@@ -289,7 +283,7 @@ pub fn view_single(
session, session,
current_admin, current_admin,
flash_data, flash_data,
content, h.div([], [content, pending_verifications_script()]),
) )
wisp.html_response(element.to_document_string(html), 200) wisp.html_response(element.to_document_string(html), 200)
} }
@@ -432,23 +426,30 @@ fn api_error_message(err: common.ApiError) -> String {
fn render_pending_verification_card( fn render_pending_verification_card(
ctx: Context, ctx: Context,
pv: verifications.PendingVerification, pv: verifications.PendingVerification,
can_review: Bool,
) -> element.Element(a) { ) -> element.Element(a) {
let metadata_warning = user_agent_warning(pv.metadata) let metadata_warning = user_agent_warning(pv.metadata)
let geoip_hint = geoip_reason_value(pv.metadata)
h.div( h.div(
[ [
a.attribute("data-review-card", "true"),
a.attribute(
"data-direct-url",
prepend_base_path(ctx, "/pending-verifications/" <> pv.user_id),
),
a.class( a.class(
"bg-white border border-neutral-200 rounded-xl shadow-sm p-6 focus:outline-none focus:ring-2 focus:ring-neutral-900", "bg-white border border-neutral-200 rounded-xl shadow-sm p-6 focus:outline-none focus:ring-2 focus:ring-neutral-900",
), ),
a.tabindex(0), a.tabindex(0),
a.attribute("data-select-card", pv.user_id),
], ],
[ [
h.div([a.class("flex items-start gap-4 mb-6")], [ h.div([a.class("flex items-start gap-4 mb-6")], [
case can_review {
True ->
h.input([
a.type_("checkbox"),
a.class("h-4 w-4 mt-1.5 rounded border-neutral-300"),
a.attribute("data-select-checkbox", pv.user_id),
])
False -> element.none()
},
h.img([ h.img([
a.src(avatar.get_user_avatar_url( a.src(avatar.get_user_avatar_url(
ctx.media_endpoint, ctx.media_endpoint,
@@ -487,6 +488,16 @@ fn render_pending_verification_card(
element.text("Registered " <> format_timestamp(pv.created_at)), element.text("Registered " <> format_timestamp(pv.created_at)),
]), ]),
]), ]),
h.div([a.class("flex flex-col items-end gap-2 ml-auto")], [
case metadata_warning {
option.Some(msg) -> ui.pill(msg, ui.PillWarning)
option.None -> element.none()
},
case geoip_hint {
option.Some(hint) -> ui.pill("GeoIP: " <> hint, ui.PillInfo)
option.None -> element.none()
},
]),
]), ]),
h.details( h.details(
[ [
@@ -515,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( h.form(
[ [
a.method("post"), a.method("post"),
action(ctx, "/pending-verifications?action=reject"), action(ctx, "/pending-verifications?action=reject"),
a.attribute("data-review-submit", "left"), a.attribute("data-async", "true"),
a.class("inline-flex w-full"), a.attribute("data-confirm", "Reject this registration?"),
], ],
[ [
h.input([ h.input([
@@ -528,42 +543,36 @@ fn render_pending_verification_card(
a.name("user_id"), a.name("user_id"),
a.value(pv.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( h.button(
[ [
a.attribute("data-review-action", "left"), a.type_("submit"),
a.attribute("data-action-type", "reject"),
a.attribute("accesskey", "r"),
a.class( a.class(
"px-4 py-2 bg-red-600 text-white rounded-lg label hover:bg-red-700 transition-colors", "px-4 py-2 bg-red-600 text-white rounded-lg label hover:bg-red-700 transition-colors",
), ),
], ],
[element.text("Reject")], [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( h.button(
[ [
a.attribute("data-review-action", "right"), a.type_("submit"),
a.attribute("data-action-type", "approve"),
a.attribute("accesskey", "a"),
a.class( a.class(
"px-4 py-2 bg-green-600 text-white rounded-lg label hover:bg-green-700 transition-colors", "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 { fn option_or_default(default: String, value: option.Option(String)) -> String {
case value { case value {
option.Some(v) -> v option.Some(v) -> v
@@ -737,6 +766,190 @@ fn empty_state() {
]) ])
} }
fn pending_verifications_script() -> element.Element(a) {
let js =
"
(function () {
const grid = document.querySelector('[data-select-grid]');
if (!grid) return;
const toolbar = document.querySelector('[data-selection-toolbar]');
const remainingEl = document.querySelector('[data-remaining-total]');
const countEl = toolbar?.querySelector('[data-selected-count]') || null;
const selectAll = toolbar?.querySelector('[data-select-all]') || null;
const bulkButtons = toolbar
? Array.from(toolbar.querySelectorAll('[data-bulk-action]'))
: [];
function showToast(message, variant) {
const box = document.createElement('div');
box.className = 'fixed left-4 right-4 bottom-4 z-50';
box.innerHTML =
'<div class=\"max-w-xl mx-auto\">' +
'<div class=\"px-4 py-3 rounded-lg shadow border ' +
(variant === 'success'
? 'bg-green-50 border-green-200 text-green-800'
: 'bg-red-50 border-red-200 text-red-800') +
'\">' +
'<div class=\"text-sm font-semibold\">' +
(variant === 'success' ? 'Saved' : 'Action failed') +
'</div>' +
'<div class=\"text-sm mt-1 break-words\">' + (message || 'OK') + '</div>' +
'</div></div>';
document.body.appendChild(box);
setTimeout(() => box.remove(), 4200);
}
function updateRemaining() {
if (!remainingEl) return;
const total = grid.querySelectorAll('[data-select-card]').length;
remainingEl.textContent = total.toString() + ' remaining';
}
function updateSelection() {
const boxes = Array.from(grid.querySelectorAll('[data-select-checkbox]'));
const selected = boxes.filter((b) => b.checked);
if (countEl) countEl.textContent = selected.length + ' selected';
bulkButtons.forEach((btn) => (btn.disabled = selected.length === 0));
if (selectAll) {
selectAll.checked = selected.length > 0 && selected.length === boxes.length;
selectAll.indeterminate =
selected.length > 0 && selected.length < boxes.length;
}
}
function cardFor(id) {
return grid.querySelector('[data-select-card=\"' + id + '\"]');
}
function removeCard(id) {
const card = cardFor(id);
if (card) card.remove();
updateRemaining();
updateSelection();
if (grid.querySelectorAll('[data-select-card]').length === 0) {
grid.innerHTML =
'<div class=\"col-span-full border border-dashed border-neutral-200 rounded-lg p-8 text-center text-neutral-500\">All registration requests have been processed</div>';
}
}
function setButtonLoading(btn, loading) {
if (!btn) return;
btn.disabled = loading;
if (loading) {
btn.dataset.originalText = btn.textContent;
btn.textContent = 'Working…';
} else if (btn.dataset.originalText) {
btn.textContent = btn.dataset.originalText;
}
}
async function submitForm(form) {
const actionUrl = new URL(form.action, window.location.origin);
actionUrl.searchParams.set('background', '1');
const fd = new FormData(form);
const body = new URLSearchParams();
fd.forEach((v, k) => body.append(k, v));
const resp = await fetch(actionUrl.toString(), {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded;charset=UTF-8' },
body: body.toString(),
credentials: 'same-origin',
});
if (!resp.ok && resp.status !== 204) {
let txt = '';
try { txt = await resp.text(); } catch (_) {}
throw new Error(txt || 'Request failed (' + resp.status + ')');
}
}
async function actOn(id, action) {
const form = grid.querySelector(
'[data-select-card=\"' + id + '\"] form[data-action-type=\"' + action + '\"]'
);
if (!form) throw new Error('Missing form for ' + action);
await submitForm(form);
removeCard(id);
}
async function handleBulk(action) {
const boxes = Array.from(grid.querySelectorAll('[data-select-checkbox]:checked'));
if (boxes.length === 0) return;
const confirmMsg =
action === 'reject'
? 'Reject ' + boxes.length + ' registration(s)?'
: 'Approve ' + boxes.length + ' registration(s)?';
if (!window.confirm(confirmMsg)) return;
const button = toolbar.querySelector('[data-bulk-action=\"' + action + '\"]');
setButtonLoading(button, true);
try {
for (const box of boxes) {
const id = box.getAttribute('data-select-checkbox');
if (!id) continue;
await actOn(id, action);
}
showToast('Completed ' + action + ' for ' + boxes.length + ' item(s)', 'success');
} catch (err) {
showToast(err && err.message ? err.message : String(err), 'error');
} finally {
setButtonLoading(button, false);
}
}
bulkButtons.forEach((btn) => {
btn.addEventListener('click', (e) => {
e.preventDefault();
const action = btn.getAttribute('data-bulk-action');
if (action) handleBulk(action);
});
});
if (selectAll) {
selectAll.addEventListener('change', (e) => {
const boxes = grid.querySelectorAll('[data-select-checkbox]');
boxes.forEach((b) => (b.checked = e.target.checked));
updateSelection();
});
}
grid.addEventListener('change', (e) => {
const target = e.target;
if (target && target.matches('[data-select-checkbox]')) {
updateSelection();
}
});
document.querySelectorAll('form[data-async]').forEach((form) => {
form.addEventListener('submit', (e) => {
e.preventDefault();
const confirmMsg = form.getAttribute('data-confirm');
if (confirmMsg && !window.confirm(confirmMsg)) return;
const btn = form.querySelector('button[type=\"submit\"]');
const id = form.querySelector('[name=\"user_id\"]')?.value;
const action = btn?.getAttribute('data-action-type') || 'action';
setButtonLoading(btn, true);
submitForm(form)
.then(() => {
if (id) removeCard(id);
showToast(
(action === 'approve' ? 'Approved ' : 'Rejected ') + (id || 'item'),
'success'
);
})
.catch((err) => showToast(err && err.message ? err.message : String(err), 'error'))
.finally(() => setButtonLoading(btn, false));
});
});
updateRemaining();
updateSelection();
})();
"
h.script([a.attribute("defer", "defer")], js)
}
fn error_view(err: common.ApiError) { fn error_view(err: common.ApiError) {
let #(title, message) = case err { let #(title, message) = case err {
common.Unauthorized -> #( common.Unauthorized -> #(

File diff suppressed because it is too large Load Diff

View File

@@ -121,8 +121,20 @@ pub fn view(
h.div( h.div(
[a.class("bg-white border border-neutral-200 rounded-lg p-6 mb-6")], [a.class("bg-white border border-neutral-200 rounded-lg p-6 mb-6")],
[ [
h.div([a.class("flex items-start gap-6")], [ h.div(
h.div([a.class("flex-shrink-0")], [ [
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([ h.img([
a.src(avatar.get_user_avatar_url( a.src(avatar.get_user_avatar_url(
ctx.media_endpoint, ctx.media_endpoint,
@@ -135,9 +147,10 @@ pub fn view(
a.alt(user_data.username), a.alt(user_data.username),
a.class("w-24 h-24 rounded-full"), a.class("w-24 h-24 rounded-full"),
]), ]),
]), ],
),
h.div([a.class("flex-1")], [ 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( ui.heading_section(
user_data.username user_data.username
<> "#" <> "#"
@@ -159,7 +172,7 @@ pub fn view(
case list.is_empty(badges) { case list.is_empty(badges) {
False -> False ->
h.div( 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) { list.map(badges, fn(b) {
h.img([ h.img([
a.src(b.icon), a.src(b.icon),
@@ -172,11 +185,11 @@ pub fn view(
True -> element.none() True -> element.none()
}, },
h.div([a.class("flex flex-wrap items-start gap-4")], [ 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")], [ h.div([a.class("text-sm font-medium text-neutral-600")], [
element.text("User ID:"), 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), element.text(user_data.id),
]), ]),
]), ]),
@@ -199,7 +212,8 @@ pub fn view(
}, },
]), ]),
]), ]),
]), ],
),
], ],
), ),
render_tabs( render_tabs(

View File

@@ -114,7 +114,7 @@ pub fn view(
fn render_search_form(ctx: Context, query: option.Option(String)) { fn render_search_form(ctx: Context, query: option.Option(String)) {
ui.card(ui.PaddingSmall, [ ui.card(ui.PaddingSmall, [
h.form([a.method("get"), a.class("flex flex-col gap-4")], [ h.form([a.method("get"), a.class("flex flex-col gap-4")], [
h.div([a.class("flex gap-2")], [ h.div([a.class("flex flex-col sm:flex-row gap-2")], [
h.input([ h.input([
a.type_("text"), a.type_("text"),
a.name("q"), a.name("q"),
@@ -125,12 +125,12 @@ fn render_search_form(ctx: Context, query: option.Option(String)) {
), ),
a.attribute("autocomplete", "off"), a.attribute("autocomplete", "off"),
]), ]),
ui.button_primary("Search", "submit", []), ui.button_primary("Search", "submit", [a.class("w-full sm:w-auto")]),
h.a( h.a(
[ [
href(ctx, "/users"), href(ctx, "/users"),
a.class( a.class(
"px-4 py-2 bg-white text-neutral-700 border border-neutral-300 rounded-lg text-sm font-medium hover:bg-neutral-50 transition-colors", "px-4 py-2 bg-white text-neutral-700 border border-neutral-300 rounded-lg text-sm font-medium hover:bg-neutral-50 transition-colors w-full sm:w-auto text-center",
), ),
], ],
[element.text("Clear")], [element.text("Clear")],

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 { fn api_error_message(err: common.ApiError) -> String {
case err { case err {
common.Unauthorized -> "Unauthorized" common.Unauthorized -> "Unauthorized"
@@ -897,20 +910,49 @@ fn handle_authenticated_request(req: Request, ctx: Context) -> Response {
Get -> { Get -> {
use user_session, current_admin <- with_session_and_admin(req, ctx) use user_session, current_admin <- with_session_and_admin(req, ctx)
let flash_data = flash.from_request(req) let flash_data = flash.from_request(req)
let admin_acls = case current_admin {
option.Some(admin) -> admin.acls
_ -> []
}
case
acl.has_permission(
admin_acls,
constants.acl_pending_verification_view,
)
{
True ->
pending_verifications_page.view( pending_verifications_page.view(
ctx, ctx,
user_session, user_session,
current_admin, current_admin,
flash_data, flash_data,
) )
False ->
wisp.response(403)
|> wisp.string_body(
"Forbidden: requires pending_verification:view permission",
)
}
} }
Post -> { Post -> {
use user_session <- with_session(req, ctx) use user_session <- with_session(req, ctx)
let query = wisp.get_query(req) let query = wisp.get_query(req)
let action = list.key_find(query, "action") |> option.from_result let action = list.key_find(query, "action") |> option.from_result
let background = get_bool_query(req, "background") let background = get_bool_query(req, "background")
let admin_result = users.get_current_admin(ctx, user_session)
let admin_acls = case admin_result {
Ok(option.Some(admin)) -> admin.acls
_ -> []
}
case
acl.has_permission(
admin_acls,
constants.acl_pending_verification_review,
)
{
True ->
pending_verifications_page.handle_action( pending_verifications_page.handle_action(
req, req,
ctx, ctx,
@@ -918,6 +960,21 @@ fn handle_authenticated_request(req: Request, ctx: Context) -> Response {
action, action,
background, background,
) )
False ->
case background {
True ->
wisp.json_response(
"{\"error\": \"Forbidden: requires pending_verification:review permission\"}",
403,
)
False ->
flash.redirect_with_error(
ctx,
"/pending-verifications",
"Forbidden: requires pending_verification:review permission",
)
}
}
} }
_ -> wisp.method_not_allowed([Get, Post]) _ -> wisp.method_not_allowed([Get, Post])
} }
@@ -932,8 +989,32 @@ fn handle_authenticated_request(req: Request, ctx: Context) -> Response {
|> option.unwrap("1") |> option.unwrap("1")
let page = let page =
int.parse(page_str) |> option.from_result |> option.unwrap(1) int.parse(page_str) |> option.from_result |> option.unwrap(1)
let admin_result = users.get_current_admin(ctx, user_session)
let admin_acls = case admin_result {
Ok(option.Some(admin)) -> admin.acls
_ -> []
}
let can_review =
acl.has_permission(
admin_acls,
constants.acl_pending_verification_review,
)
pending_verifications_page.view_fragment(ctx, user_session, page) case
acl.has_permission(
admin_acls,
constants.acl_pending_verification_view,
)
{
True ->
pending_verifications_page.view_fragment(
ctx,
user_session,
page,
can_review,
)
False -> wisp.response(403) |> wisp.string_body("Forbidden")
}
} }
_ -> wisp.method_not_allowed([Get]) _ -> wisp.method_not_allowed([Get])
} }
@@ -985,21 +1066,26 @@ fn handle_authenticated_request(req: Request, ctx: Context) -> Response {
|> option.unwrap("0") |> option.unwrap("0")
let page = let page =
int.parse(page_str) |> option.from_result |> option.unwrap(0) int.parse(page_str) |> option.from_result |> option.unwrap(0)
let fragment = get_bool_query(req, "fragment") let sort =
let table_view = get_bool_query(req, "view") list.key_find(query, "sort")
|> option.from_result
|> option.map(fn(s) {
case string.trim(s) {
"" -> option.None
v -> option.Some(v)
}
})
|> option.unwrap(option.None)
let limit_str =
list.key_find(query, "limit")
|> option.from_result
|> option.unwrap("50")
let limit =
int.parse(limit_str)
|> option.from_result
|> option.unwrap(50)
|> clamp_limit
case fragment {
True ->
reports_page.view_fragment(
ctx,
user_session,
search_query,
status_filter,
type_filter,
category_filter,
page,
)
False ->
reports_page.view_with_mode( reports_page.view_with_mode(
ctx, ctx,
user_session, user_session,
@@ -1010,10 +1096,10 @@ fn handle_authenticated_request(req: Request, ctx: Context) -> Response {
type_filter, type_filter,
category_filter, category_filter,
page, page,
table_view, limit,
sort,
) )
} }
}
_ -> wisp.method_not_allowed([Get]) _ -> wisp.method_not_allowed([Get])
} }
["reports", report_id] -> ["reports", report_id] ->

View File

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

View File

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

View File

@@ -19,14 +19,17 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import {promisify} from 'node:util'; import {promisify} from 'node:util';
import type {UserID} from '~/BrandedTypes'; import {createInviteCode, type UserID} from '~/BrandedTypes';
import {APIErrorCodes, UserFlags} from '~/Constants'; import {APIErrorCodes, UserFlags} from '~/Constants';
import {AccessDeniedError, FluxerAPIError, InputValidationError, UnauthorizedError} from '~/Errors'; import {AccessDeniedError, FluxerAPIError, InputValidationError, UnauthorizedError} from '~/Errors';
import type {IGatewayService} from '~/infrastructure/IGatewayService'; import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {IRateLimitService} from '~/infrastructure/IRateLimitService'; import type {IRateLimitService} from '~/infrastructure/IRateLimitService';
import type {PendingJoinInviteStore} from '~/infrastructure/PendingJoinInviteStore';
import type {InviteService} from '~/invite/InviteService';
import {Logger} from '~/Logger'; import {Logger} from '~/Logger';
import {getUserSearchService} from '~/Meilisearch'; import {getUserSearchService} from '~/Meilisearch';
import type {User} from '~/Models'; import type {User} from '~/Models';
import {createRequestCache} from '~/middleware/RequestCacheMiddleware';
import type {IUserRepository} from '~/user/IUserRepository'; import type {IUserRepository} from '~/user/IUserRepository';
import {mapUserToPrivateResponse} from '~/user/UserModel'; import {mapUserToPrivateResponse} from '~/user/UserModel';
import * as AgeUtils from '~/utils/AgeUtils'; import * as AgeUtils from '~/utils/AgeUtils';
@@ -61,6 +64,8 @@ export class AuthUtilityService {
private repository: IUserRepository, private repository: IUserRepository,
private rateLimitService: IRateLimitService, private rateLimitService: IRateLimitService,
private gatewayService: IGatewayService, private gatewayService: IGatewayService,
private inviteService: InviteService,
private pendingJoinInviteStore: PendingJoinInviteStore,
) {} ) {}
async generateSecureToken(length = 64): Promise<string> { async generateSecureToken(length = 64): Promise<string> {
@@ -210,5 +215,29 @@ export class AuthUtilityService {
event: 'USER_UPDATE', event: 'USER_UPDATE',
data: mapUserToPrivateResponse(updatedUser!), data: mapUserToPrivateResponse(updatedUser!),
}); });
await this.autoJoinPendingInvite(userId);
}
private async autoJoinPendingInvite(userId: UserID): Promise<void> {
const pendingInviteCode = await this.pendingJoinInviteStore.getPendingInvite(userId);
if (!pendingInviteCode) {
return;
}
try {
await this.inviteService.acceptInvite({
userId,
inviteCode: createInviteCode(pendingInviteCode),
requestCache: createRequestCache(),
});
} catch (error) {
Logger.warn(
{userId, inviteCode: pendingInviteCode, error},
'Failed to auto-join invite after redeeming beta code',
);
} finally {
await this.pendingJoinInviteStore.deletePendingInvite(userId);
}
} }
} }

View File

@@ -174,6 +174,11 @@ export abstract class BaseChannelAuthService {
async validateDMSendPermissions({channelId, userId}: {channelId: ChannelID; userId: UserID}): Promise<void> { async validateDMSendPermissions({channelId, userId}: {channelId: ChannelID; userId: UserID}): Promise<void> {
const channel = await this.channelRepository.channelData.findUnique(channelId); const channel = await this.channelRepository.channelData.findUnique(channelId);
if (!channel) throw new UnknownChannelError(); if (!channel) throw new UnknownChannelError();
if (channel.type === ChannelTypes.GROUP_DM || channel.type === ChannelTypes.DM_PERSONAL_NOTES) {
return;
}
const recipients = await this.userRepository.listUsers(Array.from(channel.recipientIds)); const recipients = await this.userRepository.listUsers(Array.from(channel.recipientIds));
await this.dmPermissionValidator.validate({recipients, userId}); await this.dmPermissionValidator.validate({recipients, userId});
} }

View File

@@ -72,6 +72,12 @@ export class CallService {
throw new InvalidChannelTypeForCallError(); throw new InvalidChannelTypeForCallError();
} }
const caller = await this.userRepository.findUnique(userId);
const isUnclaimedCaller = caller != null && !caller.passwordHash && !caller.isBot;
if (isUnclaimedCaller && channel.type === ChannelTypes.DM) {
return {ringable: false};
}
const call = await this.gatewayService.getCall(channelId); const call = await this.gatewayService.getCall(channelId);
const alreadyInCall = call ? call.voice_states.some((vs) => vs.user_id === userId.toString()) : false; const alreadyInCall = call ? call.voice_states.some((vs) => vs.user_id === userId.toString()) : false;
if (alreadyInCall) { if (alreadyInCall) {

View File

@@ -217,6 +217,8 @@ export const AdminACLs = {
USER_DISABLE_SUSPICIOUS: 'user:disable:suspicious', USER_DISABLE_SUSPICIOUS: 'user:disable:suspicious',
USER_DELETE: 'user:delete', USER_DELETE: 'user:delete',
USER_CANCEL_BULK_MESSAGE_DELETION: 'user:cancel:bulk_message_deletion', USER_CANCEL_BULK_MESSAGE_DELETION: 'user:cancel:bulk_message_deletion',
PENDING_VERIFICATION_VIEW: 'pending_verification:view',
PENDING_VERIFICATION_REVIEW: 'pending_verification:review',
BETA_CODES_GENERATE: 'beta_codes:generate', BETA_CODES_GENERATE: 'beta_codes:generate',
GIFT_CODES_GENERATE: 'gift_codes:generate', GIFT_CODES_GENERATE: 'gift_codes:generate',

View File

@@ -24,7 +24,12 @@ import {applicationIdToUserId} from '~/BrandedTypes';
import {UserFlags, UserPremiumTypes} from '~/Constants'; import {UserFlags, UserPremiumTypes} from '~/Constants';
import type {UserRow} from '~/database/CassandraTypes'; import type {UserRow} from '~/database/CassandraTypes';
import type {ApplicationRow} from '~/database/types/OAuth2Types'; import type {ApplicationRow} from '~/database/types/OAuth2Types';
import {BotUserNotFoundError, InputValidationError, UnknownApplicationError} from '~/Errors'; import {
BotUserNotFoundError,
InputValidationError,
UnclaimedAccountRestrictedError,
UnknownApplicationError,
} from '~/Errors';
import type {DiscriminatorService} from '~/infrastructure/DiscriminatorService'; import type {DiscriminatorService} from '~/infrastructure/DiscriminatorService';
import type {EntityAssetService, PreparedAssetUpload} from '~/infrastructure/EntityAssetService'; import type {EntityAssetService, PreparedAssetUpload} from '~/infrastructure/EntityAssetService';
import type {IGatewayService} from '~/infrastructure/IGatewayService'; import type {IGatewayService} from '~/infrastructure/IGatewayService';
@@ -130,6 +135,10 @@ export class ApplicationService {
const owner = await this.deps.userRepository.findUniqueAssert(args.ownerUserId); const owner = await this.deps.userRepository.findUniqueAssert(args.ownerUserId);
const botIsPublic = args.botPublic ?? true; const botIsPublic = args.botPublic ?? true;
if (!owner.passwordHash && !owner.isBot) {
throw new UnclaimedAccountRestrictedError('create applications');
}
const applicationId: ApplicationID = this.deps.snowflakeService.generate() as ApplicationID; const applicationId: ApplicationID = this.deps.snowflakeService.generate() as ApplicationID;
const botUserId = applicationIdToUserId(applicationId); const botUserId = applicationIdToUserId(applicationId);

View File

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

View File

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

View File

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

View File

@@ -18,9 +18,11 @@
*/ */
import type {ChannelID, GuildID, UserID} from '~/BrandedTypes'; import type {ChannelID, GuildID, UserID} from '~/BrandedTypes';
import {ChannelTypes} from '~/Constants';
import type {IChannelRepository} from '~/channel/IChannelRepository'; import type {IChannelRepository} from '~/channel/IChannelRepository';
import { import {
FeatureTemporarilyDisabledError, FeatureTemporarilyDisabledError,
UnclaimedAccountRestrictedError,
UnknownChannelError, UnknownChannelError,
UnknownGuildMemberError, UnknownGuildMemberError,
UnknownUserError, UnknownUserError,
@@ -114,6 +116,21 @@ export class VoiceService {
throw new UnknownChannelError(); throw new UnknownChannelError();
} }
const isUnclaimed = !user.passwordHash && !user.isBot;
if (isUnclaimed) {
if (channel.type === ChannelTypes.DM) {
throw new UnclaimedAccountRestrictedError('join 1:1 voice calls');
}
if (channel.type === ChannelTypes.GUILD_VOICE) {
const guild = guildId ? await this.guildRepository.findUnique(guildId) : null;
const isOwner = guild?.ownerId === userId;
if (!isOwner) {
throw new UnclaimedAccountRestrictedError('join voice channels you do not own');
}
}
}
let mute = false; let mute = false;
let deaf = false; let deaf = false;

View File

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

View File

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

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<title>Fluxer</title> <title>Fluxer</title>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1, user-scalable=no">
<meta name="description" content="Chat that puts you first. Built for friends, groups, and communities. Text, voice, and video. Open source and community-funded."> <meta name="description" content="Fluxer is an open-source, independent instant messaging and VoIP platform. Built for friends, groups, and communities.">
<link rel="preconnect" href="https://fluxerstatic.com"> <link rel="preconnect" href="https://fluxerstatic.com">
<link rel="stylesheet" href="https://fluxerstatic.com/fonts/ibm-plex.css"> <link rel="stylesheet" href="https://fluxerstatic.com/fonts/ibm-plex.css">
<link rel="manifest" href="/manifest.json"> <link rel="manifest" href="/manifest.json">

View File

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

View File

@@ -3,7 +3,7 @@
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"private": true, "private": true,
"description": "Fluxer desktop client. Chat that puts you first.", "description": "Fluxer is an open-source, independent instant messaging and VoIP platform. Built for friends, groups, and communities.",
"homepage": "https://fluxer.app", "homepage": "https://fluxer.app",
"author": "Fluxer Contributors <developers@fluxer.app>", "author": "Fluxer Contributors <developers@fluxer.app>",
"sideEffects": [ "sideEffects": [

View File

@@ -31,7 +31,7 @@ function generateManifest(cdnEndpointRaw) {
name: 'Fluxer', name: 'Fluxer',
short_name: 'Fluxer', short_name: 'Fluxer',
description: description:
'Chat that puts you first. Built for friends, groups, and communities. Text, voice, and video. Open source and community-funded.', 'Fluxer is an open-source, independent instant messaging and VoIP platform. Built for friends, groups, and communities.',
start_url: '/', start_url: '/',
display: 'standalone', display: 'standalone',
orientation: 'portrait-primary', orientation: 'portrait-primary',

View File

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

View File

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

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, error,
}: NativeDatePickerProps) { }: NativeDatePickerProps) {
const {t} = useLingui(); const {t} = useLingui();
const _monthPlaceholder = t`Month`;
const _dayPlaceholder = t`Day`;
const _yearPlaceholder = t`Year`;
const dateOfBirthPlaceholder = t`Date of birth`; const dateOfBirthPlaceholder = t`Date of birth`;
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();

View File

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

View File

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

View File

@@ -23,8 +23,11 @@ import {PhoneIcon, VideoCameraIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite'; import {observer} from 'mobx-react-lite';
import React from 'react'; import React from 'react';
import * as CallActionCreators from '~/actions/CallActionCreators'; import * as CallActionCreators from '~/actions/CallActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import {ChannelTypes} from '~/Constants';
import type {ChannelRecord} from '~/records/ChannelRecord'; import type {ChannelRecord} from '~/records/ChannelRecord';
import CallStateStore from '~/stores/CallStateStore'; import CallStateStore from '~/stores/CallStateStore';
import UserStore from '~/stores/UserStore';
import MediaEngineStore from '~/stores/voice/MediaEngineFacade'; import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
import * as CallUtils from '~/utils/CallUtils'; import * as CallUtils from '~/utils/CallUtils';
import {ChannelHeaderIcon} from './ChannelHeaderIcon'; import {ChannelHeaderIcon} from './ChannelHeaderIcon';
@@ -38,9 +41,20 @@ const VoiceCallButton = observer(({channel}: {channel: ChannelRecord}) => {
const hasActiveCall = CallStateStore.hasActiveCall(channel.id); const hasActiveCall = CallStateStore.hasActiveCall(channel.id);
const participants = call ? CallStateStore.getParticipants(channel.id) : []; const participants = call ? CallStateStore.getParticipants(channel.id) : [];
const participantCount = participants.length; const participantCount = participants.length;
const currentUser = UserStore.getCurrentUser();
const isUnclaimed = !(currentUser?.isClaimed() ?? false);
const is1to1 = channel.type === ChannelTypes.DM;
const blocked = isUnclaimed && is1to1;
const handleClick = React.useCallback( const handleClick = React.useCallback(
async (event: React.MouseEvent) => { async (event: React.MouseEvent) => {
if (blocked) {
ToastActionCreators.createToast({
type: 'error',
children: t`Claim your account to start or join 1:1 calls.`,
});
return;
}
if (isInCall) { if (isInCall) {
void CallActionCreators.leaveCall(channel.id); void CallActionCreators.leaveCall(channel.id);
} else if (hasActiveCall) { } else if (hasActiveCall) {
@@ -50,7 +64,7 @@ const VoiceCallButton = observer(({channel}: {channel: ChannelRecord}) => {
await CallUtils.checkAndStartCall(channel.id, silent); await CallUtils.checkAndStartCall(channel.id, silent);
} }
}, },
[channel.id, isInCall, hasActiveCall], [channel.id, isInCall, hasActiveCall, blocked],
); );
let label: string; let label: string;
@@ -67,7 +81,13 @@ const VoiceCallButton = observer(({channel}: {channel: ChannelRecord}) => {
: t`Join Voice Call (${participantCount} participants)`; : t`Join Voice Call (${participantCount} participants)`;
} }
} else { } else {
label = isInCall ? t`Leave Voice Call` : hasActiveCall ? t`Join Voice Call` : t`Start Voice Call`; label = blocked
? t`Claim your account to call`
: isInCall
? t`Leave Voice Call`
: hasActiveCall
? t`Join Voice Call`
: t`Start Voice Call`;
} }
return ( return (
@@ -76,6 +96,7 @@ const VoiceCallButton = observer(({channel}: {channel: ChannelRecord}) => {
label={label} label={label}
isSelected={isInCall} isSelected={isInCall}
onClick={handleClick} onClick={handleClick}
disabled={blocked}
keybindAction="start_pm_call" keybindAction="start_pm_call"
/> />
); );
@@ -90,9 +111,20 @@ const VideoCallButton = observer(({channel}: {channel: ChannelRecord}) => {
const hasActiveCall = CallStateStore.hasActiveCall(channel.id); const hasActiveCall = CallStateStore.hasActiveCall(channel.id);
const participants = call ? CallStateStore.getParticipants(channel.id) : []; const participants = call ? CallStateStore.getParticipants(channel.id) : [];
const participantCount = participants.length; const participantCount = participants.length;
const currentUser = UserStore.getCurrentUser();
const isUnclaimed = !(currentUser?.isClaimed() ?? false);
const is1to1 = channel.type === ChannelTypes.DM;
const blocked = isUnclaimed && is1to1;
const handleClick = React.useCallback( const handleClick = React.useCallback(
async (event: React.MouseEvent) => { async (event: React.MouseEvent) => {
if (blocked) {
ToastActionCreators.createToast({
type: 'error',
children: t`Claim your account to start or join 1:1 calls.`,
});
return;
}
if (isInCall) { if (isInCall) {
void CallActionCreators.leaveCall(channel.id); void CallActionCreators.leaveCall(channel.id);
} else if (hasActiveCall) { } else if (hasActiveCall) {
@@ -102,7 +134,7 @@ const VideoCallButton = observer(({channel}: {channel: ChannelRecord}) => {
await CallUtils.checkAndStartCall(channel.id, silent); await CallUtils.checkAndStartCall(channel.id, silent);
} }
}, },
[channel.id, isInCall, hasActiveCall], [channel.id, isInCall, hasActiveCall, blocked],
); );
let label: string; let label: string;
@@ -119,10 +151,24 @@ const VideoCallButton = observer(({channel}: {channel: ChannelRecord}) => {
: t`Join Video Call (${participantCount} participants)`; : t`Join Video Call (${participantCount} participants)`;
} }
} else { } else {
label = isInCall ? t`Leave Video Call` : hasActiveCall ? t`Join Video Call` : t`Start Video Call`; label = blocked
? t`Claim your account to call`
: isInCall
? t`Leave Video Call`
: hasActiveCall
? t`Join Video Call`
: t`Start Video Call`;
} }
return <ChannelHeaderIcon icon={VideoCameraIcon} label={label} isSelected={isInCall} onClick={handleClick} />; return (
<ChannelHeaderIcon
icon={VideoCameraIcon}
label={label}
isSelected={isInCall}
onClick={handleClick}
disabled={blocked}
/>
);
}); });
export const CallButtons = { export const CallButtons = {

View File

@@ -33,7 +33,7 @@ import type {UserRecord} from '~/records/UserRecord';
import AuthenticationStore from '~/stores/AuthenticationStore'; import AuthenticationStore from '~/stores/AuthenticationStore';
import MemberSidebarStore from '~/stores/MemberSidebarStore'; import MemberSidebarStore from '~/stores/MemberSidebarStore';
import UserStore from '~/stores/UserStore'; import UserStore from '~/stores/UserStore';
import type {GroupDMMemberGroup, MemberGroup} from '~/utils/MemberListUtils'; import type {GroupDMMemberGroup} from '~/utils/MemberListUtils';
import * as MemberListUtils from '~/utils/MemberListUtils'; import * as MemberListUtils from '~/utils/MemberListUtils';
import * as NicknameUtils from '~/utils/NicknameUtils'; import * as NicknameUtils from '~/utils/NicknameUtils';
import styles from './ChannelMembers.module.css'; import styles from './ChannelMembers.module.css';
@@ -65,35 +65,6 @@ const SkeletonMemberItem = ({index}: {index: number}) => {
); );
}; };
const _MemberListGroup = observer(
({guild, group, channelId}: {guild: GuildRecord; group: MemberGroup; channelId: string}) => (
<div className={styles.groupContainer}>
<div className={styles.groupHeader}>
{group.displayName} {group.count}
</div>
<div className={styles.membersList}>
{group.members.map((member: GuildMemberRecord) => {
const user = member.user;
const userId = user.id;
return (
<MemberListItem
key={userId}
user={user}
channelId={channelId}
guildId={guild.id}
isOwner={guild.isOwner(userId)}
roleColor={member.getColorString?.() ?? undefined}
displayName={NicknameUtils.getNickname(user, guild.id)}
disableBackdrop={true}
/>
);
})}
</div>
<div className={styles.groupSpacer} />
</div>
),
);
interface GroupDMMemberListGroupProps { interface GroupDMMemberListGroupProps {
group: GroupDMMemberGroup; group: GroupDMMemberGroup;
channelId: string; channelId: string;

View File

@@ -40,9 +40,7 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators'; import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators';
import {MessagePreviewContext} from '~/Constants'; import {MessagePreviewContext} from '~/Constants';
import {Message, type MessageBehaviorOverrides} from '~/components/channel/Message'; import {Message, type MessageBehaviorOverrides} from '~/components/channel/Message';
import {GroupDMAvatar} from '~/components/common/GroupDMAvatar';
import {MessageContextPrefix} from '~/components/shared/MessageContextPrefix/MessageContextPrefix'; import {MessageContextPrefix} from '~/components/shared/MessageContextPrefix/MessageContextPrefix';
import {Avatar} from '~/components/uikit/Avatar';
import {Button} from '~/components/uikit/Button/Button'; import {Button} from '~/components/uikit/Button/Button';
import {ContextMenuCloseProvider} from '~/components/uikit/ContextMenu/ContextMenu'; import {ContextMenuCloseProvider} from '~/components/uikit/ContextMenu/ContextMenu';
import {MenuGroup} from '~/components/uikit/ContextMenu/MenuGroup'; import {MenuGroup} from '~/components/uikit/ContextMenu/MenuGroup';
@@ -58,9 +56,7 @@ import ChannelSearchStore, {getChannelSearchContextId} from '~/stores/ChannelSea
import ChannelStore from '~/stores/ChannelStore'; import ChannelStore from '~/stores/ChannelStore';
import GuildNSFWAgreeStore from '~/stores/GuildNSFWAgreeStore'; import GuildNSFWAgreeStore from '~/stores/GuildNSFWAgreeStore';
import GuildStore from '~/stores/GuildStore'; import GuildStore from '~/stores/GuildStore';
import UserStore from '~/stores/UserStore';
import {applyChannelSearchHighlight, clearChannelSearchHighlight} from '~/utils/ChannelSearchHighlight'; import {applyChannelSearchHighlight, clearChannelSearchHighlight} from '~/utils/ChannelSearchHighlight';
import * as ChannelUtils from '~/utils/ChannelUtils';
import {goToMessage} from '~/utils/MessageNavigator'; import {goToMessage} from '~/utils/MessageNavigator';
import * as RouterUtils from '~/utils/RouterUtils'; import * as RouterUtils from '~/utils/RouterUtils';
import {tokenizeSearchQuery} from '~/utils/SearchQueryTokenizer'; import {tokenizeSearchQuery} from '~/utils/SearchQueryTokenizer';
@@ -78,14 +74,6 @@ import type {SearchMachineState} from './SearchResultsUtils';
import {areSegmentsEqual} from './SearchResultsUtils'; import {areSegmentsEqual} from './SearchResultsUtils';
import {DEFAULT_SCOPE_VALUE, getScopeOptionsForChannel} from './searchScopeOptions'; import {DEFAULT_SCOPE_VALUE, getScopeOptionsForChannel} from './searchScopeOptions';
const getChannelDisplayName = (channel: ChannelRecord): string => {
if (channel.isPrivate()) {
return ChannelUtils.getDMDisplayName(channel);
}
return channel.name?.trim() || ChannelUtils.getName(channel);
};
const getChannelGuild = (channel: ChannelRecord): GuildRecord | null => { const getChannelGuild = (channel: ChannelRecord): GuildRecord | null => {
if (!channel.guildId) { if (!channel.guildId) {
return null; return null;
@@ -101,37 +89,6 @@ const getChannelPath = (channel: ChannelRecord): string => {
return Routes.dmChannel(channel.id); return Routes.dmChannel(channel.id);
}; };
const _renderChannelIcon = (channel: ChannelRecord): React.ReactNode => {
if (channel.isPersonalNotes()) {
return ChannelUtils.getIcon(channel, {className: styles.channelIcon});
}
if (channel.isDM()) {
const recipientId = channel.recipientIds[0];
const recipient = recipientId ? UserStore.getUser(recipientId) : null;
if (recipient) {
return (
<div className={styles.channelIconAvatar}>
<Avatar user={recipient} size={20} status={null} className={styles.channelIconAvatarImage} />
</div>
);
}
return ChannelUtils.getIcon(channel, {className: styles.channelIcon});
}
if (channel.isGroupDM()) {
return (
<div className={styles.channelIconAvatar}>
<GroupDMAvatar channel={channel} size={20} disableStatusIndicator />
</div>
);
}
return ChannelUtils.getIcon(channel, {className: styles.channelIcon});
};
interface ChannelSearchResultsProps { interface ChannelSearchResultsProps {
channel: ChannelRecord; channel: ChannelRecord;
searchQuery: string; searchQuery: string;
@@ -753,7 +710,6 @@ export const ChannelSearchResults = observer(
} }
const channelGuild = getChannelGuild(messageChannel); const channelGuild = getChannelGuild(messageChannel);
const _channelDisplayName = getChannelDisplayName(messageChannel);
const showGuildMeta = shouldShowGuildMetaForScope( const showGuildMeta = shouldShowGuildMetaForScope(
channelGuild, channelGuild,
(activeScope ?? DEFAULT_SCOPE_VALUE) as MessageSearchScope, (activeScope ?? DEFAULT_SCOPE_VALUE) as MessageSearchScope,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,15 +17,19 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>. * along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {useLingui} from '@lingui/react/macro'; import {Trans, useLingui} from '@lingui/react/macro';
import {WarningCircleIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx'; import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite'; import {observer} from 'mobx-react-lite';
import React from 'react'; import React from 'react';
import * as RelationshipActionCreators from '~/actions/RelationshipActionCreators'; import * as RelationshipActionCreators from '~/actions/RelationshipActionCreators';
import {APIErrorCodes} from '~/Constants'; import {APIErrorCodes} from '~/Constants';
import {Input} from '~/components/form/Input'; import {Input} from '~/components/form/Input';
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
import {StatusSlate} from '~/components/modals/shared/StatusSlate';
import {Button} from '~/components/uikit/Button/Button'; import {Button} from '~/components/uikit/Button/Button';
import MobileLayoutStore from '~/stores/MobileLayoutStore'; import MobileLayoutStore from '~/stores/MobileLayoutStore';
import UserStore from '~/stores/UserStore';
import {getApiErrorCode} from '~/utils/ApiErrorUtils'; import {getApiErrorCode} from '~/utils/ApiErrorUtils';
import styles from './AddFriendForm.module.css'; import styles from './AddFriendForm.module.css';
@@ -35,11 +39,30 @@ interface AddFriendFormProps {
export const AddFriendForm: React.FC<AddFriendFormProps> = observer(({onSuccess}) => { export const AddFriendForm: React.FC<AddFriendFormProps> = observer(({onSuccess}) => {
const {t} = useLingui(); const {t} = useLingui();
const [input, setInput] = React.useState(''); const [input, setInput] = React.useState('');
const [isLoading, setIsLoading] = React.useState(false); const [isLoading, setIsLoading] = React.useState(false);
const [resultStatus, setResultStatus] = React.useState<'success' | 'error' | null>(null); const [resultStatus, setResultStatus] = React.useState<'success' | 'error' | null>(null);
const [errorCode, setErrorCode] = React.useState<string | null>(null); const [errorCode, setErrorCode] = React.useState<string | null>(null);
const isClaimed = UserStore.currentUser?.isClaimed() ?? true;
if (!isClaimed) {
return (
<StatusSlate
Icon={WarningCircleIcon}
title={<Trans>Claim your account</Trans>}
description={<Trans>Claim your account to send friend requests.</Trans>}
actions={[
{
text: <Trans>Claim Account</Trans>,
onClick: () => openClaimAccountModal({force: true}),
variant: 'primary',
},
]}
/>
);
}
const parseInput = (input: string): [string, string] => { const parseInput = (input: string): [string, string] => {
const parts = input.split('#'); const parts = input.split('#');
if (parts.length > 1) { if (parts.length > 1) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,14 +18,13 @@
*/ */
import {FloatingPortal} from '@floating-ui/react'; import {FloatingPortal} from '@floating-ui/react';
import {Trans, useLingui} from '@lingui/react/macro'; import {Trans} from '@lingui/react/macro';
import {PencilIcon, SealCheckIcon, SmileyIcon} from '@phosphor-icons/react'; import {PencilIcon, SmileyIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx'; import {clsx} from 'clsx';
import {AnimatePresence, motion} from 'framer-motion'; import {AnimatePresence, motion} from 'framer-motion';
import {observer} from 'mobx-react-lite'; import {observer} from 'mobx-react-lite';
import React from 'react'; import React from 'react';
import {GuildFeatures} from '~/Constants'; import {EmojiAttributionSubtext, getEmojiAttribution} from '~/components/emojis/EmojiAttributionSubtext';
import {GuildIcon} from '~/components/popouts/GuildIcon';
import {EmojiTooltipContent} from '~/components/uikit/EmojiTooltipContent/EmojiTooltipContent'; import {EmojiTooltipContent} from '~/components/uikit/EmojiTooltipContent/EmojiTooltipContent';
import FocusRing from '~/components/uikit/FocusRing/FocusRing'; import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {useTooltipPortalRoot} from '~/components/uikit/Tooltip'; import {useTooltipPortalRoot} from '~/components/uikit/Tooltip';
@@ -35,7 +34,6 @@ import {useReactionTooltip} from '~/hooks/useReactionTooltip';
import {type CustomStatus, getCustomStatusText, normalizeCustomStatus} from '~/lib/customStatus'; import {type CustomStatus, getCustomStatusText, normalizeCustomStatus} from '~/lib/customStatus';
import UnicodeEmojis from '~/lib/UnicodeEmojis'; import UnicodeEmojis from '~/lib/UnicodeEmojis';
import EmojiStore from '~/stores/EmojiStore'; import EmojiStore from '~/stores/EmojiStore';
import GuildListStore from '~/stores/GuildListStore';
import GuildStore from '~/stores/GuildStore'; import GuildStore from '~/stores/GuildStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore'; import MobileLayoutStore from '~/stores/MobileLayoutStore';
import PresenceStore from '~/stores/PresenceStore'; import PresenceStore from '~/stores/PresenceStore';
@@ -128,62 +126,6 @@ const getTooltipEmojiUrl = (status: CustomStatus): string | null => {
return null; return null;
}; };
const StatusEmojiTooltipSubtext = observer(({status}: {status: CustomStatus}) => {
const {t} = useLingui();
const isCustomEmoji = Boolean(status.emojiId);
if (!isCustomEmoji) {
return (
<span>
<Trans>This is a default emoji on Fluxer.</Trans>
</span>
);
}
const emoji = status.emojiId ? EmojiStore.getEmojiById(status.emojiId) : null;
const guildId = emoji?.guildId;
const isMember = guildId ? GuildListStore.guilds.some((guild) => guild.id === guildId) : false;
if (!isMember) {
return (
<span>
<Trans>This is a custom emoji from a community. Ask the author for an invite to use this emoji.</Trans>
</span>
);
}
const guild = guildId ? GuildStore.getGuild(guildId) : null;
if (!guild) {
return (
<span>
<Trans>This is a custom emoji from a community.</Trans>
</span>
);
}
const isVerified = guild.features.has(GuildFeatures.VERIFIED);
return (
<div className={styles.emojiTooltipSubtext}>
<span>
<Trans>This is a custom emoji from</Trans>
</span>
<div className={styles.emojiTooltipGuildRow}>
<div className={styles.emojiTooltipGuildIcon}>
<GuildIcon id={guild.id} name={guild.name} icon={guild.icon} sizePx={20} />
</div>
<span className={styles.emojiTooltipGuildName}>{guild.name}</span>
{isVerified && (
<Tooltip text={t`Verified Community`} position="top">
<SealCheckIcon className={styles.emojiTooltipVerifiedIcon} />
</Tooltip>
)}
</div>
</div>
);
});
interface StatusEmojiWithTooltipProps { interface StatusEmojiWithTooltipProps {
status: CustomStatus; status: CustomStatus;
children: React.ReactNode; children: React.ReactNode;
@@ -195,6 +137,13 @@ const StatusEmojiWithTooltip = observer(
({status, children, onClick, isButton = false}: StatusEmojiWithTooltipProps) => { ({status, children, onClick, isButton = false}: StatusEmojiWithTooltipProps) => {
const tooltipPortalRoot = useTooltipPortalRoot(); const tooltipPortalRoot = useTooltipPortalRoot();
const {targetRef, tooltipRef, state, updatePosition, handlers, tooltipHandlers} = useReactionTooltip(500); const {targetRef, tooltipRef, state, updatePosition, handlers, tooltipHandlers} = useReactionTooltip(500);
const emoji = status.emojiId ? EmojiStore.getEmojiById(status.emojiId) : null;
const attribution = getEmojiAttribution({
emojiId: status.emojiId,
guildId: emoji?.guildId ?? null,
guild: emoji?.guildId ? GuildStore.getGuild(emoji.guildId) : null,
emojiName: status.emojiName,
});
const getEmojiDisplayName = (): string => { const getEmojiDisplayName = (): string => {
if (status.emojiId) { if (status.emojiId) {
@@ -257,7 +206,18 @@ const StatusEmojiWithTooltip = observer(
emoji={shouldUseNativeEmoji && status.emojiName && !status.emojiId ? status.emojiName : undefined} emoji={shouldUseNativeEmoji && status.emojiName && !status.emojiId ? status.emojiName : undefined}
emojiAlt={status.emojiName ?? undefined} emojiAlt={status.emojiName ?? undefined}
primaryContent={emojiName} primaryContent={emojiName}
subtext={<StatusEmojiTooltipSubtext status={status} />} subtext={
<EmojiAttributionSubtext
attribution={attribution}
classes={{
container: styles.emojiTooltipSubtext,
guildRow: styles.emojiTooltipGuildRow,
guildIcon: styles.emojiTooltipGuildIcon,
guildName: styles.emojiTooltipGuildName,
verifiedIcon: styles.emojiTooltipVerifiedIcon,
}}
/>
}
/> />
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>

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/>. * along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {Trans, useLingui} from '@lingui/react/macro';
import {SealCheckIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite'; import {observer} from 'mobx-react-lite';
import {GuildFeatures} from '~/Constants'; import {EmojiAttributionSubtext, getEmojiAttribution} from '~/components/emojis/EmojiAttributionSubtext';
import {GuildIcon} from '~/components/popouts/GuildIcon';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import type {Emoji} from '~/stores/EmojiStore'; import type {Emoji} from '~/stores/EmojiStore';
import GuildListStore from '~/stores/GuildListStore';
import GuildStore from '~/stores/GuildStore'; import GuildStore from '~/stores/GuildStore';
import styles from './EmojiInfoContent.module.css'; import styles from './EmojiInfoContent.module.css';
@@ -33,62 +28,25 @@ interface EmojiInfoContentProps {
} }
export const EmojiInfoContent = observer(function EmojiInfoContent({emoji}: EmojiInfoContentProps) { export const EmojiInfoContent = observer(function EmojiInfoContent({emoji}: EmojiInfoContentProps) {
const {t} = useLingui(); const guild = emoji.guildId ? GuildStore.getGuild(emoji.guildId) : null;
const isCustomEmoji = Boolean(emoji.guildId || emoji.id); const attribution = getEmojiAttribution({
emojiId: emoji.id,
if (!isCustomEmoji) { guildId: emoji.guildId,
return ( guild,
<div className={styles.container}> emojiName: emoji.name,
<span className={styles.text}> });
<Trans>This is a default emoji on Fluxer.</Trans>
</span>
</div>
);
}
const guildId = emoji.guildId;
const isMember = guildId ? GuildListStore.guilds.some((guild) => guild.id === guildId) : false;
if (!isMember) {
return (
<div className={styles.container}>
<span className={styles.text}>
<Trans>This is a custom emoji from a community. Ask the author for an invite to use this emoji.</Trans>
</span>
</div>
);
}
const guild = guildId ? GuildStore.getGuild(guildId) : null;
if (!guild) {
return (
<div className={styles.container}>
<span className={styles.text}>
<Trans>This is a custom emoji from a community.</Trans>
</span>
</div>
);
}
const isVerified = guild.features.has(GuildFeatures.VERIFIED);
return ( return (
<div className={styles.container}> <EmojiAttributionSubtext
<span className={styles.text}> attribution={attribution}
<Trans>This is a custom emoji from</Trans> classes={{
</span> container: styles.container,
<div className={styles.guildRow}> text: styles.text,
<div className={styles.guildIcon}> guildRow: styles.guildRow,
<GuildIcon id={guild.id} name={guild.name} icon={guild.icon} sizePx={20} /> guildIcon: styles.guildIcon,
</div> guildName: styles.guildName,
<span className={styles.guildName}>{guild.name}</span> verifiedIcon: styles.verifiedIcon,
{isVerified && ( }}
<Tooltip text={t`Verified Community`} position="top"> />
<SealCheckIcon className={styles.verifiedIcon} />
</Tooltip>
)}
</div>
</div>
); );
}); });

View File

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

View File

@@ -76,6 +76,7 @@ import ReadStateStore from '~/stores/ReadStateStore';
import SelectedChannelStore from '~/stores/SelectedChannelStore'; import SelectedChannelStore from '~/stores/SelectedChannelStore';
import TrustedDomainStore from '~/stores/TrustedDomainStore'; import TrustedDomainStore from '~/stores/TrustedDomainStore';
import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore'; import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore';
import UserStore from '~/stores/UserStore';
import MediaEngineStore from '~/stores/voice/MediaEngineFacade'; import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
import * as ChannelUtils from '~/utils/ChannelUtils'; import * as ChannelUtils from '~/utils/ChannelUtils';
@@ -194,10 +195,18 @@ export const ChannelItem = observer(
const showKeyboardAffordances = keyboardModeEnabled && isFocused; const showKeyboardAffordances = keyboardModeEnabled && isFocused;
const currentUserId = AuthenticationStore.currentUserId; const currentUserId = AuthenticationStore.currentUserId;
const currentUser = UserStore.getCurrentUser();
const isUnclaimed = !(currentUser?.isClaimed() ?? false);
const isGuildOwner = currentUser ? guild.isOwner(currentUser.id) : false;
const currentMember = currentUserId ? GuildMemberStore.getMember(guild.id, currentUserId) : null; const currentMember = currentUserId ? GuildMemberStore.getMember(guild.id, currentUserId) : null;
const isCurrentUserTimedOut = Boolean(currentMember?.isTimedOut()); const isCurrentUserTimedOut = Boolean(currentMember?.isTimedOut());
const voiceBlockedForUnclaimed = channelIsVoice && isUnclaimed && !isGuildOwner;
const voiceTooltipText = const voiceTooltipText =
channelIsVoice && isCurrentUserTimedOut ? t`You can't join while you're on timeout.` : undefined; channelIsVoice && isCurrentUserTimedOut
? t`You can't join while you're on timeout.`
: channelIsVoice && voiceBlockedForUnclaimed
? t`Claim your account to join this voice channel.`
: undefined;
const isVoiceSelected = const isVoiceSelected =
channel.type === ChannelTypes.GUILD_VOICE && channel.type === ChannelTypes.GUILD_VOICE &&
@@ -367,6 +376,13 @@ export const ChannelItem = observer(
}); });
return; return;
} }
if (channel.type === ChannelTypes.GUILD_VOICE && voiceBlockedForUnclaimed) {
ToastActionCreators.createToast({
type: 'error',
children: t`Claim your account to join this voice channel.`,
});
return;
}
if (channel.type === ChannelTypes.GUILD_CATEGORY) { if (channel.type === ChannelTypes.GUILD_CATEGORY) {
onToggle?.(); onToggle?.();
return; return;
@@ -512,6 +528,7 @@ export const ChannelItem = observer(
contextMenuOpen && styles.contextMenuOpen, contextMenuOpen && styles.contextMenuOpen,
showKeyboardAffordances && styles.keyboardFocus, showKeyboardAffordances && styles.keyboardFocus,
channelIsVoice && styles.channelItemVoice, channelIsVoice && styles.channelItemVoice,
voiceBlockedForUnclaimed && styles.channelItemDisabled,
)} )}
onClick={handleSelect} onClick={handleSelect}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,6 +22,7 @@ import {observer} from 'mobx-react-lite';
import {useEffect, useMemo, useState} from 'react'; import {useEffect, useMemo, useState} from 'react';
import {useForm} from 'react-hook-form'; import {useForm} from 'react-hook-form';
import * as ModalActionCreators from '~/actions/ModalActionCreators'; import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators'; import * as ToastActionCreators from '~/actions/ToastActionCreators';
import * as UserActionCreators from '~/actions/UserActionCreators'; import * as UserActionCreators from '~/actions/UserActionCreators';
import {Form} from '~/components/form/Form'; import {Form} from '~/components/form/Form';
@@ -31,6 +32,7 @@ import confirmStyles from '~/components/modals/ConfirmModal.module.css';
import * as Modal from '~/components/modals/Modal'; import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button'; import {Button} from '~/components/uikit/Button/Button';
import {useFormSubmit} from '~/hooks/useFormSubmit'; import {useFormSubmit} from '~/hooks/useFormSubmit';
import ModalStore from '~/stores/ModalStore';
interface FormInputs { interface FormInputs {
email: string; email: string;
@@ -230,3 +232,20 @@ export const ClaimAccountModal = observer(() => {
</Modal.Root> </Modal.Root>
); );
}); });
const CLAIM_ACCOUNT_MODAL_KEY = 'claim-account-modal';
let hasShownClaimAccountModalThisSession = false;
export const openClaimAccountModal = ({force = false}: {force?: boolean} = {}): void => {
if (ModalStore.hasModal(CLAIM_ACCOUNT_MODAL_KEY)) {
return;
}
if (!force && hasShownClaimAccountModalThisSession) {
return;
}
hasShownClaimAccountModalThisSession = true;
ModalActionCreators.pushWithKey(
modal(() => <ClaimAccountModal />),
CLAIM_ACCOUNT_MODAL_KEY,
);
};

View File

@@ -23,12 +23,14 @@ import {observer} from 'mobx-react-lite';
import React from 'react'; import React from 'react';
import * as GiftActionCreators from '~/actions/GiftActionCreators'; import * as GiftActionCreators from '~/actions/GiftActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators'; import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
import * as Modal from '~/components/modals/Modal'; import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button'; import {Button} from '~/components/uikit/Button/Button';
import {Spinner} from '~/components/uikit/Spinner'; import {Spinner} from '~/components/uikit/Spinner';
import i18n from '~/i18n'; import i18n from '~/i18n';
import {UserRecord} from '~/records/UserRecord'; import {UserRecord} from '~/records/UserRecord';
import GiftStore from '~/stores/GiftStore'; import GiftStore from '~/stores/GiftStore';
import UserStore from '~/stores/UserStore';
import {getGiftDurationText} from '~/utils/giftUtils'; import {getGiftDurationText} from '~/utils/giftUtils';
import styles from './GiftAcceptModal.module.css'; import styles from './GiftAcceptModal.module.css';
@@ -41,6 +43,7 @@ export const GiftAcceptModal = observer(function GiftAcceptModal({code}: GiftAcc
const giftState = GiftStore.gifts.get(code) ?? null; const giftState = GiftStore.gifts.get(code) ?? null;
const gift = giftState?.data ?? null; const gift = giftState?.data ?? null;
const [isRedeeming, setIsRedeeming] = React.useState(false); const [isRedeeming, setIsRedeeming] = React.useState(false);
const isUnclaimed = !(UserStore.currentUser?.isClaimed() ?? false);
React.useEffect(() => { React.useEffect(() => {
if (!giftState) { if (!giftState) {
@@ -64,6 +67,10 @@ export const GiftAcceptModal = observer(function GiftAcceptModal({code}: GiftAcc
}; };
const handleRedeem = async () => { const handleRedeem = async () => {
if (isUnclaimed) {
openClaimAccountModal({force: true});
return;
}
setIsRedeeming(true); setIsRedeeming(true);
try { try {
await GiftActionCreators.redeem(i18n, code); await GiftActionCreators.redeem(i18n, code);
@@ -130,6 +137,42 @@ export const GiftAcceptModal = observer(function GiftAcceptModal({code}: GiftAcc
const renderGift = () => { const renderGift = () => {
const durationText = getGiftDurationText(i18n, gift!); const durationText = getGiftDurationText(i18n, gift!);
if (isUnclaimed) {
return (
<>
<div className={styles.card}>
<div className={styles.cardGrid}>
<div className={`${styles.iconCircle} ${styles.iconCircleInactive}`}>
<GiftIcon className={styles.icon} weight="fill" />
</div>
<div className={styles.cardContent}>
<h3 className={`${styles.title} ${styles.titlePrimary}`}>{durationText}</h3>
{creator && (
<span className={styles.subtitle}>{t`From ${creator.username}#${creator.discriminator}`}</span>
)}
<span className={styles.helpText}>
<Trans>Claim your account to redeem this gift.</Trans>
</span>
</div>
</div>
</div>
<div className={styles.footer}>
<Button variant="secondary" onClick={handleDismiss}>
<Trans>Maybe later</Trans>
</Button>
<Button
variant="primary"
onClick={() => {
openClaimAccountModal({force: true});
handleDismiss();
}}
>
<Trans>Claim Account</Trans>
</Button>
</div>
</>
);
}
return ( return (
<> <>
<div className={styles.card}> <div className={styles.card}>

View File

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

View File

@@ -230,6 +230,17 @@ export const InviteModal = observer(({channelId}: {channelId: string}) => {
<span className={styles.channelName}>{channel.name}</span> <span className={styles.channelName}>{channel.name}</span>
</Trans> </Trans>
</p> </p>
{invitesDisabled && (
<div className={styles.warningContainer}>
<WarningCircleIcon className={styles.warningIcon} weight="fill" />
<p className={styles.warningText}>
<Trans>
Invites are currently disabled in this community by an admin. While this invite can be created, it
cannot be accepted until invites are re-enabled.
</Trans>
</p>
</div>
)}
<div className={selectorStyles.headerSearch}> <div className={selectorStyles.headerSearch}>
<Input <Input
value={searchQuery} value={searchQuery}
@@ -248,7 +259,6 @@ export const InviteModal = observer(({channelId}: {channelId: string}) => {
<Spinner /> <Spinner />
</div> </div>
) : !showAdvanced ? ( ) : !showAdvanced ? (
<>
<RecipientList <RecipientList
recipients={recipients} recipients={recipients}
sendingTo={sendingTo} sendingTo={sendingTo}
@@ -262,19 +272,6 @@ export const InviteModal = observer(({channelId}: {channelId: string}) => {
onSearchQueryChange={setSearchQuery} onSearchQueryChange={setSearchQuery}
showSearchInput={false} showSearchInput={false}
/> />
{invitesDisabled && (
<div className={styles.warningContainer}>
<WarningCircleIcon className={styles.warningIcon} weight="fill" />
<p className={styles.warningText}>
<Trans>
Invites are currently disabled in this community by an admin. While this invite can be created, it
cannot be accepted until invites are re-enabled.
</Trans>
</p>
</div>
)}
</>
) : ( ) : (
<div className={styles.advancedView}> <div className={styles.advancedView}>
<Select <Select

View File

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

View File

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

View File

@@ -32,11 +32,9 @@ import {ConfirmModal} from '~/components/modals/ConfirmModal';
import {ClientInfo} from '~/components/modals/components/ClientInfo'; import {ClientInfo} from '~/components/modals/components/ClientInfo';
import {LogoutModal} from '~/components/modals/components/LogoutModal'; import {LogoutModal} from '~/components/modals/components/LogoutModal';
import styles from '~/components/modals/components/MobileSettingsView.module.css'; import styles from '~/components/modals/components/MobileSettingsView.module.css';
import {ScrollSpyProvider, useScrollSpyContext} from '~/components/modals/hooks/ScrollSpyContext';
import type {MobileNavigationState} from '~/components/modals/hooks/useMobileNavigation'; import type {MobileNavigationState} from '~/components/modals/hooks/useMobileNavigation';
import {useSettingsContentKey} from '~/components/modals/hooks/useSettingsContentKey'; import {useSettingsContentKey} from '~/components/modals/hooks/useSettingsContentKey';
import { import {
MobileSectionNav,
MobileSettingsDangerItem, MobileSettingsDangerItem,
MobileHeader as SharedMobileHeader, MobileHeader as SharedMobileHeader,
} from '~/components/modals/shared/MobileSettingsComponents'; } from '~/components/modals/shared/MobileSettingsComponents';
@@ -44,16 +42,13 @@ import userSettingsStyles from '~/components/modals/UserSettingsModal.module.css
import {getSettingsTabComponent} from '~/components/modals/utils/desktopSettingsTabs'; import {getSettingsTabComponent} from '~/components/modals/utils/desktopSettingsTabs';
import { import {
getCategoryLabel, getCategoryLabel,
getSectionIdsForTab,
getSectionsForTab,
type SettingsTab, type SettingsTab,
tabHasSections,
type UserSettingsTabType, type UserSettingsTabType,
} from '~/components/modals/utils/settingsConstants'; } from '~/components/modals/utils/settingsConstants';
import {filterSettingsTabsForDeveloperMode} from '~/components/modals/utils/settingsTabFilters'; import {filterSettingsTabsForDeveloperMode} from '~/components/modals/utils/settingsTabFilters';
import {Button} from '~/components/uikit/Button/Button'; import {Button} from '~/components/uikit/Button/Button';
import {MentionBadgeAnimated} from '~/components/uikit/MentionBadge'; import {MentionBadgeAnimated} from '~/components/uikit/MentionBadge';
import {Scroller, type ScrollerHandle} from '~/components/uikit/Scroller'; import {Scroller} from '~/components/uikit/Scroller';
import {Spinner} from '~/components/uikit/Spinner'; import {Spinner} from '~/components/uikit/Spinner';
import {usePressable} from '~/hooks/usePressable'; import {usePressable} from '~/hooks/usePressable';
import {usePushSubscriptions} from '~/hooks/usePushSubscriptions'; import {usePushSubscriptions} from '~/hooks/usePushSubscriptions';
@@ -397,26 +392,7 @@ const headerFadeVariants = {
exit: {opacity: 0}, exit: {opacity: 0},
}; };
interface MobileSectionNavWrapperProps {
tabType: UserSettingsTabType;
}
const MobileSectionNavWrapper: React.FC<MobileSectionNavWrapperProps> = observer(({tabType}) => {
const {t} = useLingui();
const scrollSpyContext = useScrollSpyContext();
const sections = getSectionsForTab(tabType, t);
if (!scrollSpyContext || sections.length === 0) {
return null;
}
const {activeSectionId, scrollToSection} = scrollSpyContext;
return <MobileSectionNav sections={sections} activeSectionId={activeSectionId} onSectionClick={scrollToSection} />;
});
interface MobileContentWithScrollSpyProps { interface MobileContentWithScrollSpyProps {
tabType: UserSettingsTabType;
scrollKey: string; scrollKey: string;
initialGuildId?: string; initialGuildId?: string;
initialSubtab?: string; initialSubtab?: string;
@@ -424,21 +400,9 @@ interface MobileContentWithScrollSpyProps {
} }
const MobileContentWithScrollSpy: React.FC<MobileContentWithScrollSpyProps> = observer( const MobileContentWithScrollSpy: React.FC<MobileContentWithScrollSpyProps> = observer(
({tabType, scrollKey, initialGuildId, initialSubtab, currentTabComponent}) => { ({scrollKey, initialGuildId, initialSubtab, currentTabComponent}) => {
const scrollerRef = React.useRef<ScrollerHandle | null>(null); return (
const scrollContainerRef = React.useRef<HTMLElement | null>(null); <Scroller className={styles.scrollerFlex} key={scrollKey} data-settings-scroll-container>
const sectionIds = React.useMemo(() => getSectionIdsForTab(tabType), [tabType]);
const hasSections = tabHasSections(tabType);
React.useEffect(() => {
if (scrollerRef.current) {
scrollContainerRef.current = scrollerRef.current.getScrollerNode();
}
});
const content = (
<Scroller ref={scrollerRef} className={styles.scrollerFlex} key={scrollKey} data-settings-scroll-container>
{hasSections && <MobileSectionNavWrapper tabType={tabType} />}
<div className={styles.contentContainer}> <div className={styles.contentContainer}>
{currentTabComponent && {currentTabComponent &&
React.createElement(currentTabComponent, { React.createElement(currentTabComponent, {
@@ -448,16 +412,6 @@ const MobileContentWithScrollSpy: React.FC<MobileContentWithScrollSpyProps> = ob
</div> </div>
</Scroller> </Scroller>
); );
if (hasSections) {
return (
<ScrollSpyProvider sectionIds={sectionIds} containerRef={scrollContainerRef}>
{content}
</ScrollSpyProvider>
);
}
return content;
}, },
); );
@@ -582,7 +536,6 @@ export const MobileSettingsView: React.FC<MobileSettingsViewProps> = observer(
style={{willChange: 'transform'}} style={{willChange: 'transform'}}
> >
<MobileContentWithScrollSpy <MobileContentWithScrollSpy
tabType={currentTab.type}
scrollKey={scrollKey} scrollKey={scrollKey}
initialGuildId={initialGuildId} initialGuildId={initialGuildId}
initialSubtab={initialSubtab} initialSubtab={initialSubtab}

View File

@@ -94,6 +94,17 @@ export const PlutoniumContent: React.FC<{defaultGiftMode?: boolean}> = observer(
mobileLayoutState.enabled, mobileLayoutState.enabled,
); );
const isClaimed = currentUser?.isClaimed() ?? false;
const purchaseDisabled = !isClaimed;
const purchaseDisabledTooltip = <Trans>Claim your account to purchase Fluxer Plutonium.</Trans>;
const handleSelectPlanGuarded = React.useCallback(
(plan: 'monthly' | 'yearly' | 'visionary' | 'gift1Month' | 'gift1Year' | 'giftVisionary') => {
if (purchaseDisabled) return;
handleSelectPlan(plan);
},
[handleSelectPlan, purchaseDisabled],
);
const monthlyPrice = React.useMemo(() => getFormattedPrice(PricingTier.Monthly, countryCode), [countryCode]); const monthlyPrice = React.useMemo(() => getFormattedPrice(PricingTier.Monthly, countryCode), [countryCode]);
const yearlyPrice = React.useMemo(() => getFormattedPrice(PricingTier.Yearly, countryCode), [countryCode]); const yearlyPrice = React.useMemo(() => getFormattedPrice(PricingTier.Yearly, countryCode), [countryCode]);
const visionaryPrice = React.useMemo(() => getFormattedPrice(PricingTier.Visionary, countryCode), [countryCode]); const visionaryPrice = React.useMemo(() => getFormattedPrice(PricingTier.Visionary, countryCode), [countryCode]);
@@ -221,12 +232,14 @@ export const PlutoniumContent: React.FC<{defaultGiftMode?: boolean}> = observer(
scrollToPerks={scrollToPerks} scrollToPerks={scrollToPerks}
handlePerksKeyDown={handlePerksKeyDown} handlePerksKeyDown={handlePerksKeyDown}
navigateToRedeemGift={navigateToRedeemGift} navigateToRedeemGift={navigateToRedeemGift}
handleSelectPlan={handleSelectPlan} handleSelectPlan={handleSelectPlanGuarded}
handleOpenCustomerPortal={handleOpenCustomerPortal} handleOpenCustomerPortal={handleOpenCustomerPortal}
handleReactivateSubscription={handleReactivateSubscription} handleReactivateSubscription={handleReactivateSubscription}
handleCancelSubscription={handleCancelSubscription} handleCancelSubscription={handleCancelSubscription}
handleCommunityButtonPointerDown={handleCommunityButtonPointerDown} handleCommunityButtonPointerDown={handleCommunityButtonPointerDown}
handleCommunityButtonClick={handleCommunityButtonClick} handleCommunityButtonClick={handleCommunityButtonClick}
purchaseDisabled={purchaseDisabled}
purchaseDisabledTooltip={purchaseDisabledTooltip}
/> />
<div className={styles.disclaimerContainer}> <div className={styles.disclaimerContainer}>
<PurchaseDisclaimer align="center" isPremium /> <PurchaseDisclaimer align="center" isPremium />
@@ -245,7 +258,9 @@ export const PlutoniumContent: React.FC<{defaultGiftMode?: boolean}> = observer(
loadingCheckout={loadingCheckout} loadingCheckout={loadingCheckout}
loadingSlots={loadingSlots} loadingSlots={loadingSlots}
isVisionarySoldOut={isVisionarySoldOut} isVisionarySoldOut={isVisionarySoldOut}
handleSelectPlan={handleSelectPlan} handleSelectPlan={handleSelectPlanGuarded}
purchaseDisabled={purchaseDisabled}
purchaseDisabledTooltip={purchaseDisabledTooltip}
/> />
) : ( ) : (
<GiftSection <GiftSection
@@ -257,7 +272,9 @@ export const PlutoniumContent: React.FC<{defaultGiftMode?: boolean}> = observer(
loadingCheckout={loadingCheckout} loadingCheckout={loadingCheckout}
loadingSlots={loadingSlots} loadingSlots={loadingSlots}
isVisionarySoldOut={isVisionarySoldOut} isVisionarySoldOut={isVisionarySoldOut}
handleSelectPlan={handleSelectPlan} handleSelectPlan={handleSelectPlanGuarded}
purchaseDisabled={purchaseDisabled}
purchaseDisabledTooltip={purchaseDisabledTooltip}
/> />
)} )}
@@ -280,7 +297,9 @@ export const PlutoniumContent: React.FC<{defaultGiftMode?: boolean}> = observer(
isGiftSubscription={subscriptionStatus.isGiftSubscription} isGiftSubscription={subscriptionStatus.isGiftSubscription}
loadingCheckout={loadingCheckout} loadingCheckout={loadingCheckout}
loadingSlots={loadingSlots} loadingSlots={loadingSlots}
handleSelectPlan={handleSelectPlan} handleSelectPlan={handleSelectPlanGuarded}
purchaseDisabled={purchaseDisabled}
purchaseDisabledTooltip={purchaseDisabledTooltip}
/> />
) : null} ) : null}
@@ -302,7 +321,9 @@ export const PlutoniumContent: React.FC<{defaultGiftMode?: boolean}> = observer(
loadingCheckout={loadingCheckout} loadingCheckout={loadingCheckout}
loadingSlots={loadingSlots} loadingSlots={loadingSlots}
isVisionarySoldOut={isVisionarySoldOut} isVisionarySoldOut={isVisionarySoldOut}
handleSelectPlan={handleSelectPlan} handleSelectPlan={handleSelectPlanGuarded}
purchaseDisabled={purchaseDisabled}
purchaseDisabledTooltip={purchaseDisabledTooltip}
/> />
)} )}
</div> </div>

View File

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

View File

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

View File

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

View File

@@ -27,6 +27,7 @@ import gridStyles from '../PricingGrid.module.css';
import {PurchaseDisclaimer} from '../PurchaseDisclaimer'; import {PurchaseDisclaimer} from '../PurchaseDisclaimer';
import {ToggleButton} from '../ToggleButton'; import {ToggleButton} from '../ToggleButton';
import styles from './PricingSection.module.css'; import styles from './PricingSection.module.css';
import {PurchaseDisabledWrapper} from './PurchaseDisabledWrapper';
interface PricingSectionProps { interface PricingSectionProps {
isGiftMode: boolean; isGiftMode: boolean;
@@ -39,6 +40,8 @@ interface PricingSectionProps {
loadingSlots: boolean; loadingSlots: boolean;
isVisionarySoldOut: boolean; isVisionarySoldOut: boolean;
handleSelectPlan: (plan: 'monthly' | 'yearly' | 'visionary' | 'gift1Month' | 'gift1Year' | 'giftVisionary') => void; handleSelectPlan: (plan: 'monthly' | 'yearly' | 'visionary' | 'gift1Month' | 'gift1Year' | 'giftVisionary') => void;
purchaseDisabled?: boolean;
purchaseDisabledTooltip?: React.ReactNode;
} }
export const PricingSection: React.FC<PricingSectionProps> = observer( export const PricingSection: React.FC<PricingSectionProps> = observer(
@@ -53,8 +56,11 @@ export const PricingSection: React.FC<PricingSectionProps> = observer(
loadingSlots, loadingSlots,
isVisionarySoldOut, isVisionarySoldOut,
handleSelectPlan, handleSelectPlan,
purchaseDisabled = false,
purchaseDisabledTooltip,
}) => { }) => {
const {t} = useLingui(); const {t} = useLingui();
const tooltipText: React.ReactNode = purchaseDisabledTooltip ?? t`Claim your account to purchase Fluxer Plutonium.`;
return ( return (
<section className={styles.section}> <section className={styles.section}>
@@ -67,13 +73,17 @@ export const PricingSection: React.FC<PricingSectionProps> = observer(
<div className={gridStyles.gridThreeColumns}> <div className={gridStyles.gridThreeColumns}>
{!isGiftMode ? ( {!isGiftMode ? (
<> <>
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
<PricingCard <PricingCard
title={t`Monthly`} title={t`Monthly`}
price={monthlyPrice} price={monthlyPrice}
period={t`per month`} period={t`per month`}
onSelect={() => handleSelectPlan('monthly')} onSelect={() => handleSelectPlan('monthly')}
isLoading={loadingCheckout || loadingSlots} isLoading={loadingCheckout || loadingSlots}
disabled={purchaseDisabled}
/> />
</PurchaseDisabledWrapper>
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
<PricingCard <PricingCard
title={t`Yearly`} title={t`Yearly`}
price={yearlyPrice} price={yearlyPrice}
@@ -83,7 +93,10 @@ export const PricingSection: React.FC<PricingSectionProps> = observer(
onSelect={() => handleSelectPlan('yearly')} onSelect={() => handleSelectPlan('yearly')}
buttonText={t`Upgrade Now`} buttonText={t`Upgrade Now`}
isLoading={loadingCheckout || loadingSlots} isLoading={loadingCheckout || loadingSlots}
disabled={purchaseDisabled}
/> />
</PurchaseDisabledWrapper>
<PurchaseDisabledWrapper disabled={purchaseDisabled || isVisionarySoldOut} tooltipText={tooltipText}>
<PricingCard <PricingCard
title={t`Visionary`} title={t`Visionary`}
price={visionaryPrice} price={visionaryPrice}
@@ -91,12 +104,14 @@ export const PricingSection: React.FC<PricingSectionProps> = observer(
remainingSlots={loadingSlots ? undefined : visionarySlots?.remaining} remainingSlots={loadingSlots ? undefined : visionarySlots?.remaining}
onSelect={() => handleSelectPlan('visionary')} onSelect={() => handleSelectPlan('visionary')}
isLoading={loadingCheckout || loadingSlots} isLoading={loadingCheckout || loadingSlots}
disabled={isVisionarySoldOut} disabled={purchaseDisabled || isVisionarySoldOut}
soldOut={isVisionarySoldOut} soldOut={isVisionarySoldOut}
/> />
</PurchaseDisabledWrapper>
</> </>
) : ( ) : (
<> <>
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
<PricingCard <PricingCard
title={t`1 Year Gift`} title={t`1 Year Gift`}
price={yearlyPrice} price={yearlyPrice}
@@ -105,7 +120,10 @@ export const PricingSection: React.FC<PricingSectionProps> = observer(
onSelect={() => handleSelectPlan('gift1Year')} onSelect={() => handleSelectPlan('gift1Year')}
buttonText={t`Buy Gift`} buttonText={t`Buy Gift`}
isLoading={loadingCheckout || loadingSlots} isLoading={loadingCheckout || loadingSlots}
disabled={purchaseDisabled}
/> />
</PurchaseDisabledWrapper>
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
<PricingCard <PricingCard
title={t`1 Month Gift`} title={t`1 Month Gift`}
price={monthlyPrice} price={monthlyPrice}
@@ -114,7 +132,10 @@ export const PricingSection: React.FC<PricingSectionProps> = observer(
onSelect={() => handleSelectPlan('gift1Month')} onSelect={() => handleSelectPlan('gift1Month')}
buttonText={t`Buy Gift`} buttonText={t`Buy Gift`}
isLoading={loadingCheckout || loadingSlots} isLoading={loadingCheckout || loadingSlots}
disabled={purchaseDisabled}
/> />
</PurchaseDisabledWrapper>
<PurchaseDisabledWrapper disabled={purchaseDisabled || isVisionarySoldOut} tooltipText={tooltipText}>
<PricingCard <PricingCard
title={t`Visionary Gift`} title={t`Visionary Gift`}
price={visionaryPrice} price={visionaryPrice}
@@ -123,9 +144,10 @@ export const PricingSection: React.FC<PricingSectionProps> = observer(
onSelect={() => handleSelectPlan('giftVisionary')} onSelect={() => handleSelectPlan('giftVisionary')}
buttonText={t`Buy Gift`} buttonText={t`Buy Gift`}
isLoading={loadingCheckout || loadingSlots} isLoading={loadingCheckout || loadingSlots}
disabled={isVisionarySoldOut} disabled={purchaseDisabled || isVisionarySoldOut}
soldOut={isVisionarySoldOut} soldOut={isVisionarySoldOut}
/> />
</PurchaseDisabledWrapper>
</> </>
)} )}
</div> </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/>. * along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {Trans} from '@lingui/react/macro'; import {Trans, useLingui} from '@lingui/react/macro';
import {DotsThreeIcon} from '@phosphor-icons/react'; import {DotsThreeIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx'; import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite'; import {observer} from 'mobx-react-lite';
import type React from 'react'; import type React from 'react';
import {Button} from '~/components/uikit/Button/Button'; import {Button} from '~/components/uikit/Button/Button';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import type {UserRecord} from '~/records/UserRecord'; import type {UserRecord} from '~/records/UserRecord';
import {PerksButton} from '../PerksButton'; import {PerksButton} from '../PerksButton';
import type {GracePeriodInfo} from './hooks/useSubscriptionStatus'; import type {GracePeriodInfo} from './hooks/useSubscriptionStatus';
@@ -61,6 +62,8 @@ interface SubscriptionCardProps {
handleCancelSubscription: () => void; handleCancelSubscription: () => void;
handleCommunityButtonPointerDown: (event: React.PointerEvent) => void; handleCommunityButtonPointerDown: (event: React.PointerEvent) => void;
handleCommunityButtonClick: (event: React.MouseEvent<HTMLButtonElement>) => void; handleCommunityButtonClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
purchaseDisabled?: boolean;
purchaseDisabledTooltip?: React.ReactNode;
} }
export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer( export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
@@ -97,9 +100,25 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
handleCancelSubscription, handleCancelSubscription,
handleCommunityButtonPointerDown, handleCommunityButtonPointerDown,
handleCommunityButtonClick, handleCommunityButtonClick,
purchaseDisabled = false,
purchaseDisabledTooltip,
}) => { }) => {
const {t} = useLingui();
const {isInGracePeriod, isExpired: isFullyExpired, graceEndDate} = gracePeriodInfo; const {isInGracePeriod, isExpired: isFullyExpired, graceEndDate} = gracePeriodInfo;
const isPremium = currentUser.isPremium(); const isPremium = currentUser.isPremium();
const tooltipText: string | (() => React.ReactNode) =
purchaseDisabledTooltip != null
? () => purchaseDisabledTooltip
: t`Claim your account to purchase or redeem Fluxer Plutonium.`;
const wrapIfDisabled = (element: React.ReactElement, key: string, disabled: boolean) =>
disabled ? (
<Tooltip key={key} text={tooltipText}>
<div>{element}</div>
</Tooltip>
) : (
element
);
return ( return (
<div className={clsx(styles.card, subscriptionCardColorClass)}> <div className={clsx(styles.card, subscriptionCardColorClass)}>
@@ -251,30 +270,47 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
<div className={styles.actions}> <div className={styles.actions}>
{isGiftSubscription ? ( {isGiftSubscription ? (
<> <>
<Button variant="inverted" onClick={navigateToRedeemGift} small className={styles.actionButton}> {wrapIfDisabled(
<Button
variant="inverted"
onClick={navigateToRedeemGift}
small
className={styles.actionButton}
disabled={purchaseDisabled}
>
<Trans>Redeem Gift Code</Trans> <Trans>Redeem Gift Code</Trans>
</Button> </Button>,
{!isVisionary && !isVisionarySoldOut && ( 'redeem-gift',
purchaseDisabled,
)}
{!isVisionary &&
!isVisionarySoldOut &&
wrapIfDisabled(
<Button <Button
variant="inverted" variant="inverted"
onClick={() => handleSelectPlan('visionary')} onClick={() => handleSelectPlan('visionary')}
submitting={loadingCheckout || loadingSlots} submitting={loadingCheckout || loadingSlots}
small small
className={styles.actionButton} className={styles.actionButton}
disabled={purchaseDisabled}
> >
<Trans>Upgrade to Visionary</Trans> <Trans>Upgrade to Visionary</Trans>
</Button> </Button>,
'upgrade-gift-visionary',
purchaseDisabled,
)} )}
</> </>
) : ( ) : (
<> <>
{hasEverPurchased && ( {hasEverPurchased &&
wrapIfDisabled(
<Button <Button
variant="inverted" variant="inverted"
onClick={shouldUseReactivateQuickAction ? handleReactivateSubscription : handleOpenCustomerPortal} onClick={shouldUseReactivateQuickAction ? handleReactivateSubscription : handleOpenCustomerPortal}
submitting={shouldUseReactivateQuickAction ? loadingReactivate : loadingPortal} submitting={shouldUseReactivateQuickAction ? loadingReactivate : loadingPortal}
small small
className={styles.actionButton} className={styles.actionButton}
disabled={purchaseDisabled && shouldUseReactivateQuickAction}
> >
{isFullyExpired ? ( {isFullyExpired ? (
<Trans>Resubscribe</Trans> <Trans>Resubscribe</Trans>
@@ -287,7 +323,9 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
) : ( ) : (
<Trans>Manage Subscription</Trans> <Trans>Manage Subscription</Trans>
)} )}
</Button> </Button>,
'manage-reactivate',
purchaseDisabled && shouldUseReactivateQuickAction,
)} )}
{isVisionary && ( {isVisionary && (
@@ -305,16 +343,21 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
</Button> </Button>
)} )}
{!isVisionary && !isVisionarySoldOut && ( {!isVisionary &&
!isVisionarySoldOut &&
wrapIfDisabled(
<Button <Button
variant="inverted" variant="inverted"
onClick={() => handleSelectPlan('visionary')} onClick={() => handleSelectPlan('visionary')}
submitting={loadingCheckout || loadingSlots} submitting={loadingCheckout || loadingSlots}
small small
className={styles.actionButton} className={styles.actionButton}
disabled={purchaseDisabled}
> >
<Trans>Upgrade to Visionary</Trans> <Trans>Upgrade to Visionary</Trans>
</Button> </Button>,
'upgrade-visionary',
purchaseDisabled,
)} )}
{shouldUseCancelQuickAction && ( {shouldUseCancelQuickAction && (

View File

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

View File

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

View File

@@ -36,13 +36,16 @@ interface StatusSlateProps {
description: React.ReactNode; description: React.ReactNode;
actions?: Array<StatusAction>; actions?: Array<StatusAction>;
fullHeight?: boolean; fullHeight?: boolean;
iconClassName?: string;
iconStyle?: React.CSSProperties;
} }
export const StatusSlate: React.FC<StatusSlateProps> = observer( export const StatusSlate: React.FC<StatusSlateProps> = observer(
({Icon, title, description, actions = [], fullHeight = false}) => { ({Icon, title, description, actions = [], fullHeight = false, iconClassName, iconStyle}) => {
const iconClass = [styles.icon, iconClassName].filter(Boolean).join(' ');
return ( return (
<div className={`${styles.container} ${fullHeight ? styles.fullHeight : ''}`}> <div className={`${styles.container} ${fullHeight ? styles.fullHeight : ''}`}>
<Icon className={styles.icon} aria-hidden /> <Icon className={iconClass} style={iconStyle} aria-hidden />
<h3 className={styles.title}>{title}</h3> <h3 className={styles.title}>{title}</h3>
<p className={styles.description}>{description}</p> <p className={styles.description}>{description}</p>
{actions.length > 0 && ( {actions.length > 0 && (

View File

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

View File

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

View File

@@ -96,3 +96,7 @@
display: flex; display: flex;
gap: 0.5rem; 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 {modal} from '~/actions/ModalActionCreators';
import * as UserActionCreators from '~/actions/UserActionCreators'; import * as UserActionCreators from '~/actions/UserActionCreators';
import {BackupCodesViewModal} from '~/components/modals/BackupCodesViewModal'; import {BackupCodesViewModal} from '~/components/modals/BackupCodesViewModal';
import {ClaimAccountModal} from '~/components/modals/ClaimAccountModal'; import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
import {ConfirmModal} from '~/components/modals/ConfirmModal'; import {ConfirmModal} from '~/components/modals/ConfirmModal';
import {MfaTotpDisableModal} from '~/components/modals/MfaTotpDisableModal'; import {MfaTotpDisableModal} from '~/components/modals/MfaTotpDisableModal';
import {MfaTotpEnableModal} from '~/components/modals/MfaTotpEnableModal'; import {MfaTotpEnableModal} from '~/components/modals/MfaTotpEnableModal';
@@ -202,7 +202,7 @@ export const SecurityTabContent: React.FC<SecurityTabProps> = observer(
<Trans>Claim your account to access security features like two-factor authentication and passkeys.</Trans> <Trans>Claim your account to access security features like two-factor authentication and passkeys.</Trans>
} }
> >
<Button onClick={() => ModalActionCreators.push(modal(() => <ClaimAccountModal />))}> <Button className={styles.claimButton} fitContent onClick={() => openClaimAccountModal()}>
<Trans>Claim Account</Trans> <Trans>Claim Account</Trans>
</Button> </Button>
</SettingsTabSection> </SettingsTabSection>

View File

@@ -17,7 +17,7 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>. * along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {Trans} from '@lingui/react/macro'; import {Trans, useLingui} from '@lingui/react/macro';
import {BookOpenIcon, WarningCircleIcon} from '@phosphor-icons/react'; import {BookOpenIcon, WarningCircleIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite'; import {observer} from 'mobx-react-lite';
import React from 'react'; import React from 'react';
@@ -38,12 +38,16 @@ import styles from '~/components/modals/tabs/ApplicationsTab/ApplicationsTab.mod
import ApplicationsTabStore from '~/components/modals/tabs/ApplicationsTab/ApplicationsTabStore'; import ApplicationsTabStore from '~/components/modals/tabs/ApplicationsTab/ApplicationsTabStore';
import {Button} from '~/components/uikit/Button/Button'; import {Button} from '~/components/uikit/Button/Button';
import {Spinner} from '~/components/uikit/Spinner'; import {Spinner} from '~/components/uikit/Spinner';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import type {DeveloperApplication} from '~/records/DeveloperApplicationRecord'; import type {DeveloperApplication} from '~/records/DeveloperApplicationRecord';
import UserStore from '~/stores/UserStore';
const ApplicationsTab: React.FC = observer(() => { const ApplicationsTab: React.FC = observer(() => {
const {t} = useLingui();
const {checkUnsavedChanges} = useUnsavedChangesFlash('applications'); const {checkUnsavedChanges} = useUnsavedChangesFlash('applications');
const {setContentKey} = useSettingsContentKey(); const {setContentKey} = useSettingsContentKey();
const store = ApplicationsTabStore; const store = ApplicationsTabStore;
const isUnclaimed = !(UserStore.currentUser?.isClaimed() ?? false);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
setContentKey(store.contentKey); setContentKey(store.contentKey);
@@ -138,9 +142,19 @@ const ApplicationsTab: React.FC = observer(() => {
description={<Trans>Create and manage applications and bots for your account.</Trans>} description={<Trans>Create and manage applications and bots for your account.</Trans>}
> >
<div className={styles.buttonContainer}> <div className={styles.buttonContainer}>
{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}> <Button variant="primary" fitContainer={false} fitContent onClick={openCreateModal}>
<Trans>Create Application</Trans> <Trans>Create Application</Trans>
</Button> </Button>
)}
<a className={styles.documentationLink} href="https://fluxer.dev" target="_blank" rel="noreferrer"> <a className={styles.documentationLink} href="https://fluxer.dev" target="_blank" rel="noreferrer">
<BookOpenIcon weight="fill" size={18} className={styles.documentationIcon} /> <BookOpenIcon weight="fill" size={18} className={styles.documentationIcon} />
<Trans>Read the Documentation (fluxer.dev)</Trans> <Trans>Read the Documentation (fluxer.dev)</Trans>

View File

@@ -25,6 +25,7 @@ import {observer} from 'mobx-react-lite';
import React from 'react'; import React from 'react';
import * as BetaCodeActionCreators from '~/actions/BetaCodeActionCreators'; import * as BetaCodeActionCreators from '~/actions/BetaCodeActionCreators';
import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators'; import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators';
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
import { import {
SettingsTabContainer, SettingsTabContainer,
SettingsTabContent, SettingsTabContent,
@@ -39,6 +40,7 @@ import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import type {BetaCodeRecord} from '~/records/BetaCodeRecord'; import type {BetaCodeRecord} from '~/records/BetaCodeRecord';
import BetaCodeStore from '~/stores/BetaCodeStore'; import BetaCodeStore from '~/stores/BetaCodeStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore'; import MobileLayoutStore from '~/stores/MobileLayoutStore';
import UserStore from '~/stores/UserStore';
import * as DateUtils from '~/utils/DateUtils'; import * as DateUtils from '~/utils/DateUtils';
import styles from './BetaCodesTab.module.css'; import styles from './BetaCodesTab.module.css';
@@ -247,10 +249,12 @@ const BetaCodesTab: React.FC = observer(() => {
const fetchStatus = BetaCodeStore.fetchStatus; const fetchStatus = BetaCodeStore.fetchStatus;
const allowance = BetaCodeStore.allowance; const allowance = BetaCodeStore.allowance;
const nextResetAt = BetaCodeStore.nextResetAt; const nextResetAt = BetaCodeStore.nextResetAt;
const isClaimed = UserStore.currentUser?.isClaimed() ?? false;
React.useEffect(() => { React.useEffect(() => {
if (!isClaimed) return;
BetaCodeActionCreators.fetch(); BetaCodeActionCreators.fetch();
}, []); }, [isClaimed]);
const sortedBetaCodes = React.useMemo(() => { const sortedBetaCodes = React.useMemo(() => {
return [...betaCodes].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); return [...betaCodes].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
@@ -280,6 +284,27 @@ const BetaCodesTab: React.FC = observer(() => {
return i18n._(msg`${allowance} codes remaining this week`); return i18n._(msg`${allowance} codes remaining this week`);
}, [allowance, nextResetAt, i18n]); }, [allowance, nextResetAt, i18n]);
if (!isClaimed) {
return (
<SettingsTabContainer>
<SettingsTabContent>
<StatusSlate
Icon={TicketIcon}
title={<Trans>Claim your account</Trans>}
description={<Trans>Claim your account to generate beta codes.</Trans>}
actions={[
{
text: <Trans>Claim Account</Trans>,
onClick: () => openClaimAccountModal({force: true}),
variant: 'primary',
},
]}
/>
</SettingsTabContent>
</SettingsTabContainer>
);
}
if (fetchStatus === 'pending' || fetchStatus === 'idle') { if (fetchStatus === 'pending' || fetchStatus === 'idle') {
return ( return (
<div className={styles.spinnerContainer}> <div className={styles.spinnerContainer}>

View File

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

View File

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

View File

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

View File

@@ -18,7 +18,7 @@
*/ */
import {Trans, useLingui} from '@lingui/react/macro'; import {Trans, useLingui} from '@lingui/react/macro';
import {CaretDownIcon, CheckIcon, CopyIcon, GiftIcon, NetworkSlashIcon} from '@phosphor-icons/react'; import {CaretDownIcon, CheckIcon, CopyIcon, GiftIcon, NetworkSlashIcon, WarningCircleIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx'; import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite'; import {observer} from 'mobx-react-lite';
import React from 'react'; import React from 'react';
@@ -31,6 +31,7 @@ import * as UserActionCreators from '~/actions/UserActionCreators';
import {UserPremiumTypes} from '~/Constants'; import {UserPremiumTypes} from '~/Constants';
import {Form} from '~/components/form/Form'; import {Form} from '~/components/form/Form';
import {Input} from '~/components/form/Input'; import {Input} from '~/components/form/Input';
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
import {StatusSlate} from '~/components/modals/shared/StatusSlate'; import {StatusSlate} from '~/components/modals/shared/StatusSlate';
import {Button} from '~/components/uikit/Button/Button'; import {Button} from '~/components/uikit/Button/Button';
import {Spinner} from '~/components/uikit/Spinner'; import {Spinner} from '~/components/uikit/Spinner';
@@ -180,6 +181,7 @@ const GiftInventoryTab: React.FC = observer(() => {
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(false); const [error, setError] = React.useState(false);
const [expandedGiftId, setExpandedGiftId] = React.useState<string | null>(null); const [expandedGiftId, setExpandedGiftId] = React.useState<string | null>(null);
const isUnclaimed = !(UserStore.currentUser?.isClaimed() ?? false);
const giftCodeForm = useForm<GiftCodeFormInputs>({defaultValues: {code: ''}}); const giftCodeForm = useForm<GiftCodeFormInputs>({defaultValues: {code: ''}});
@@ -201,6 +203,10 @@ const GiftInventoryTab: React.FC = observer(() => {
}); });
const fetchGifts = React.useCallback(async () => { const fetchGifts = React.useCallback(async () => {
if (isUnclaimed) {
setLoading(false);
return;
}
try { try {
setError(false); setError(false);
const userGifts = await GiftActionCreators.fetchUserGifts(); const userGifts = await GiftActionCreators.fetchUserGifts();
@@ -211,7 +217,7 @@ const GiftInventoryTab: React.FC = observer(() => {
setError(true); setError(true);
setLoading(false); setLoading(false);
} }
}, []); }, [isUnclaimed]);
React.useEffect(() => { React.useEffect(() => {
fetchGifts(); fetchGifts();
@@ -225,6 +231,23 @@ const GiftInventoryTab: React.FC = observer(() => {
fetchGifts(); fetchGifts();
}; };
if (isUnclaimed) {
return (
<StatusSlate
Icon={WarningCircleIcon}
title={<Trans>Claim your account</Trans>}
description={<Trans>Claim your account to redeem or manage Plutonium gift codes.</Trans>}
actions={[
{
text: <Trans>Claim Account</Trans>,
onClick: () => openClaimAccountModal({force: true}),
variant: 'primary',
},
]}
/>
);
}
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div> <div>

View File

@@ -17,96 +17,221 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>. * along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/ */
.container { .page {
--report-max-width: 640px;
max-width: var(--report-max-width);
width: 100%;
margin: 0 auto;
padding: 1rem 0 2rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.25rem; gap: 1rem;
max-width: 32rem;
margin: 0 auto;
text-align: left;
} }
.stepIndicator { .breadcrumbs {
font-size: 0.75rem; display: flex;
line-height: 1rem; align-items: center;
letter-spacing: 0.06em; gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 0.25rem;
}
.breadcrumbShell {
position: sticky;
top: 0;
z-index: 5;
padding-top: 0.3rem;
padding-bottom: 0.5rem;
margin-bottom: 0.25rem;
min-height: 2.5rem;
display: flex;
align-items: center;
width: 100%;
}
.breadcrumbPlaceholder {
width: 100%;
height: 1.5rem;
}
.breadcrumbStep {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.6rem;
border-radius: 0.5rem;
background: none;
border: none;
color: var(--text-tertiary);
font-size: 0.85rem;
cursor: pointer;
transition:
color 0.1s ease,
background 0.1s ease;
}
.breadcrumbStep:disabled {
opacity: 0.6;
cursor: default;
}
.breadcrumbStep:hover:not(:disabled) {
color: var(--text-secondary);
}
.breadcrumbActive {
color: var(--text-primary);
cursor: default;
background: color-mix(in srgb, var(--background-modifier-accent) 60%, transparent);
}
.breadcrumbActive:hover {
color: var(--text-primary);
}
.breadcrumbNumber {
display: flex;
align-items: center;
justify-content: center;
width: 1.4rem;
height: 1.4rem;
border-radius: 50%;
background: var(--background-modifier-accent);
font-weight: 600;
font-size: 0.8rem;
}
.breadcrumbActive .breadcrumbNumber {
background: var(--brand-primary);
color: white;
}
.breadcrumbLabel {
font-weight: 600;
}
.breadcrumbSeparator {
color: var(--text-tertiary);
font-size: 0.9rem;
}
.card {
background: transparent;
border: none;
border-radius: 16px;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
margin: 0;
box-shadow: none;
}
.cardHeader {
display: flex;
flex-direction: column;
gap: 0.2rem;
width: 100%;
}
.cardBody {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
min-height: 1px;
}
.eyebrow {
margin: 0;
text-transform: uppercase; text-transform: uppercase;
color: var(--text-muted); letter-spacing: 0.06em;
text-align: center; font-size: 0.75rem;
color: var(--text-tertiary);
} }
.title { .title {
margin-bottom: 0.25rem; margin: 0;
text-align: center; font-size: 1.3rem;
font-size: 1.25rem; line-height: 1.6rem;
line-height: 1.75rem; font-weight: 700;
font-weight: 600;
letter-spacing: 0.025em;
color: var(--text-primary); color: var(--text-primary);
} }
.description { .description {
margin-bottom: 0.75rem; margin: 0.2rem 0 0;
text-align: center; font-size: 0.95rem;
font-size: 0.875rem; line-height: 1.45rem;
line-height: 1.25rem; color: var(--text-secondary);
color: var(--text-tertiary);
}
.metaLine {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--text-tertiary);
text-align: center;
}
.metaValue {
color: var(--text-primary);
font-weight: 500;
}
.metaSpacer {
color: var(--text-muted);
} }
.form { .form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
align-items: stretch;
} }
.footer { .footerLinks {
margin-top: 1.25rem; display: flex;
flex-direction: column;
gap: 0.4rem;
}
.actionRow {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 0.75rem;
align-items: center; width: 100%;
text-align: center;
} }
.footerRow { @media (min-width: 640px) {
.actionRow {
flex-direction: row;
align-items: center;
justify-content: flex-start;
}
}
.actionButton {
align-self: flex-start;
}
.linkRow {
display: flex; display: flex;
gap: 1rem; align-items: center;
justify-content: center; gap: 0.45rem;
flex-wrap: wrap; font-size: 0.9rem;
color: var(--text-secondary);
}
.linkButton {
all: unset;
font-size: 0.9rem;
font-weight: 600;
color: var(--text-secondary);
cursor: pointer;
}
.linkButton:hover {
color: var(--text-primary);
text-decoration: underline;
}
.linkSeparator {
color: var(--background-modifier-accent);
} }
.link { .link {
font-size: 0.875rem; font-size: 0.9rem;
line-height: 1.25rem;
color: var(--text-tertiary); color: var(--text-tertiary);
text-decoration: none; text-decoration: none;
background: none; background: none;
border: none; border: none;
cursor: pointer; cursor: pointer;
transition-property: color;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
padding: 0; padding: 0;
transition: color 120ms ease;
} }
.link:hover { .link:hover {
@@ -121,44 +246,24 @@
} }
.errorBox { .errorBox {
margin-bottom: 0.5rem;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
background-color: color-mix(in srgb, var(--status-danger) 15%, transparent); background-color: color-mix(in srgb, var(--status-danger) 15%, transparent);
color: var(--status-danger); color: var(--status-danger);
font-size: 0.875rem; font-size: 0.9rem;
line-height: 1.25rem; line-height: 1.3rem;
text-align: left;
}
.successBox {
margin-top: 0.25rem;
padding: 0.75rem 1rem;
border-radius: var(--radius-lg);
background-color: color-mix(in srgb, var(--status-success) 12%, transparent);
color: var(--text-primary);
text-align: left;
}
.successLabel {
font-size: 0.75rem;
line-height: 1rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-tertiary);
margin-bottom: 0.25rem;
}
.successValue {
font-size: 1rem;
line-height: 1.5rem;
font-weight: 600;
letter-spacing: 0.02em;
word-break: break-word;
} }
.helperText { .helperText {
font-size: 0.75rem; font-size: 0.8rem;
color: var(--text-muted); color: var(--text-muted);
text-align: left; }
.mainColumn {
min-width: 0;
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
margin: 0;
} }

View File

@@ -17,327 +17,138 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>. * along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {Trans, useLingui} from '@lingui/react/macro'; import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite'; import {observer} from 'mobx-react-lite';
import React from 'react'; import React from 'react';
import {Input, Textarea} from '~/components/form/Input'; import * as ToastActionCreators from '~/actions/ToastActionCreators';
import {Select, type SelectOption} from '~/components/form/Select'; import type {SelectOption} from '~/components/form/Select';
import {Button} from '~/components/uikit/Button/Button'; import type {RadioOption} from '~/components/uikit/RadioGroup/RadioGroup';
import {RadioGroup, type RadioOption} from '~/components/uikit/RadioGroup/RadioGroup'; import {AuthLayoutContext} from '~/contexts/AuthLayoutContext';
import {Endpoints} from '~/Endpoints'; import {Endpoints} from '~/Endpoints';
import {useFluxerDocumentTitle} from '~/hooks/useFluxerDocumentTitle'; import {useFluxerDocumentTitle} from '~/hooks/useFluxerDocumentTitle';
import HttpClient from '~/lib/HttpClient'; import HttpClient from '~/lib/HttpClient';
import styles from './ReportPage.module.css'; 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 ValidationError = {path: string; message: string};
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;
}
}
export const ReportPage = observer(() => { export const ReportPage = observer(() => {
const {t} = useLingui(); const {t} = useLingui();
const authLayout = React.useContext(AuthLayoutContext);
useFluxerDocumentTitle(t`Report Illegal Content`); 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 [state, dispatch] = React.useReducer(reducer, undefined, createInitialState);
const reportTypeOptions = React.useMemo<ReadonlyArray<RadioOption<ReportType>>>( const parseValidationErrors = React.useCallback(
() => [ (
{value: 'message', name: t`Report a Message`}, error: unknown,
{value: 'user', name: t`Report a User Profile`}, ): {fieldErrors: Partial<Record<keyof FormValues, string>>; generalMessage: string | null} | null => {
{value: 'guild', name: t`Report a Community`}, 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], [t],
); );
const reportTypeLabel = React.useMemo(() => { const reportTypeOptions = React.useMemo<ReadonlyArray<RadioOption<ReportType>>>(() => {
const match = reportTypeOptions.find((o) => o.value === state.selectedType); return REPORT_TYPE_OPTION_DESCRIPTORS.map((option) => ({
return match?.name ?? ''; value: option.value,
}, [reportTypeOptions, state.selectedType]); name: t(option.name),
}));
}, [t]);
const messageCategoryOptions = React.useMemo<Array<SelectOption<string>>>( const messageCategoryOptions = React.useMemo<Array<SelectOption<string>>>(() => {
() => [ return MESSAGE_CATEGORY_OPTIONS.map((option) => ({
{value: '', label: t`Select a category`}, value: option.value,
{value: 'harassment', label: t`Harassment or Bullying`}, label: t(option.label),
{value: 'hate_speech', label: t`Hate Speech`}, }));
{value: 'violent_content', label: t`Violent or Graphic Content`}, }, [t]);
{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 userCategoryOptions = React.useMemo<Array<SelectOption<string>>>( const userCategoryOptions = React.useMemo<Array<SelectOption<string>>>(() => {
() => [ return USER_CATEGORY_OPTIONS.map((option) => ({
{value: '', label: t`Select a category`}, value: option.value,
{value: 'harassment', label: t`Harassment or Bullying`}, label: t(option.label),
{value: 'hate_speech', label: t`Hate Speech`}, }));
{value: 'spam_account', label: t`Spam Account`}, }, [t]);
{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 guildCategoryOptions = React.useMemo<Array<SelectOption<string>>>( const guildCategoryOptions = React.useMemo<Array<SelectOption<string>>>(() => {
() => [ return GUILD_CATEGORY_OPTIONS.map((option) => ({
{value: '', label: t`Select a category`}, value: option.value,
{value: 'harassment', label: t`Harassment`}, label: t(option.label),
{value: 'hate_speech', label: t`Hate Speech`}, }));
{value: 'extremist_community', label: t`Extremist Community`}, }, [t]);
{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 countryOptions = React.useMemo<Array<SelectOption<string>>>( const countryOptions = React.useMemo<Array<SelectOption<string>>>(() => {
() => [ return COUNTRY_OPTIONS.map((option) => ({
{value: '', label: t`Select a country`}, value: option.value,
{value: 'AT', label: t`Austria`}, label: t(option.label),
{value: 'BE', label: t`Belgium`}, }));
{value: 'BG', label: t`Bulgaria`}, }, [t]);
{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 categoryOptionsByType = React.useMemo(() => { const categoryOptionsByType = React.useMemo(() => {
return { return {
@@ -349,7 +160,11 @@ export const ReportPage = observer(() => {
const categoryOptions = state.selectedType ? categoryOptionsByType[state.selectedType] : []; 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(() => { React.useEffect(() => {
if (state.flowStep === 'selection') return; if (state.flowStep === 'selection') return;
@@ -399,6 +214,9 @@ export const ReportPage = observer(() => {
dispatch({type: 'SET_ERROR', message: null}); dispatch({type: 'SET_ERROR', message: null});
dispatch({type: 'SENDING_CODE', value: true}); dispatch({type: 'SENDING_CODE', value: true});
if (state.flowStep === 'verification') {
dispatch({type: 'START_RESEND_COOLDOWN', seconds: 30});
}
try { try {
await HttpClient.post({ await HttpClient.post({
@@ -408,12 +226,19 @@ export const ReportPage = observer(() => {
dispatch({type: 'SET_EMAIL', email: normalizedEmail}); dispatch({type: 'SET_EMAIL', email: normalizedEmail});
dispatch({type: 'GO_TO_VERIFICATION'}); dispatch({type: 'GO_TO_VERIFICATION'});
if (state.flowStep === 'verification') {
ToastActionCreators.createToast({type: 'success', children: t`Code resent`});
}
} catch (_error) { } catch (_error) {
dispatch({type: 'SET_ERROR', message: t`Failed to send verification code. Please try again.`}); 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 { } finally {
dispatch({type: 'SENDING_CODE', value: false}); 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 () => { const verifyCode = React.useCallback(async () => {
if (state.isSendingCode || state.isVerifying || state.isSubmitting) return; if (state.isSendingCode || state.isVerifying || state.isSubmitting) return;
@@ -464,6 +289,8 @@ export const ReportPage = observer(() => {
return; return;
} }
dispatch({type: 'CLEAR_FIELD_ERRORS'});
const reporterFullName = state.formValues.reporterFullName.trim(); const reporterFullName = state.formValues.reporterFullName.trim();
const reporterCountry = state.formValues.reporterCountry; const reporterCountry = state.formValues.reporterCountry;
const reporterFluxerTag = state.formValues.reporterFluxerTag.trim(); const reporterFluxerTag = state.formValues.reporterFluxerTag.trim();
@@ -560,222 +387,17 @@ export const ReportPage = observer(() => {
dispatch({type: 'SUBMIT_SUCCESS', reportId: response.body.report_id}); dispatch({type: 'SUBMIT_SUCCESS', reportId: response.body.report_id});
} catch (_error) { } 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: 'SET_ERROR', message: t`Something went wrong while submitting the report. Please try again.`});
}
dispatch({type: 'SUBMITTING', value: false}); dispatch({type: 'SUBMITTING', value: false});
} }
}, [state, t]); }, [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 reporterFullName = state.formValues.reporterFullName.trim();
const reporterCountry = state.formValues.reporterCountry; const reporterCountry = state.formValues.reporterCountry;
const category = state.formValues.category; const category = state.formValues.category;
@@ -796,177 +418,118 @@ export const ReportPage = observer(() => {
userTargetOk && userTargetOk &&
guildTargetOk; 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 ( return (
<div className={styles.container}> <ReportStepSelection
{renderStepHeader(<Trans>Report Details</Trans>, <Trans>Please provide the details of your report.</Trans>)} reportTypeOptions={reportTypeOptions}
selectedType={state.selectedType}
<div className={styles.metaLine}> onSelect={onSelectType}
<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}
/> />
);
{state.selectedType === 'message' && ( case 'email':
<> return (
<Input <ReportStepEmail
label={t`Message Link`} email={state.email}
type="url" errorMessage={state.errorMessage}
value={state.formValues.messageLink} isSending={state.isSendingCode}
onChange={(e) => dispatch({type: 'SET_FORM_FIELD', field: 'messageLink', value: e.target.value})} onEmailChange={(value) => dispatch({type: 'SET_EMAIL', email: value})}
placeholder="https://fluxer.app/channels/..." onSubmit={() => void sendVerificationCode()}
autoComplete="off" onStartOver={() => dispatch({type: 'GO_TO_SELECTION'})}
footer={ />
!state.formValues.messageLink.trim() ? undefined : !messageLinkOk ? ( );
<span className={styles.helperText}>
<Trans>That doesn&apos;t look like a valid URL.</Trans> case 'verification':
</span> return (
) : undefined <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' && ( case 'details':
<> return (
<Input <ReportStepDetails
label={t`User ID (optional)`} selectedType={state.selectedType as ReportType}
type="text" formValues={state.formValues}
value={state.formValues.userId} categoryOptions={categoryOptions}
onChange={(e) => dispatch({type: 'SET_FORM_FIELD', field: 'userId', value: e.target.value})} countryOptions={countryOptions}
placeholder="123456789012345678" fieldErrors={state.fieldErrors}
autoComplete="off" 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" case 'complete':
value={state.formValues.userTag} return state.successReportId ? <ReportStepComplete onStartOver={() => dispatch({type: 'RESET_ALL'})} /> : null;
onChange={(e) => dispatch({type: 'SET_FORM_FIELD', field: 'userTag', value: e.target.value})}
placeholder="username#1234" default:
autoComplete="off" return null;
footer={
userTargetOk ? undefined : (
<span className={styles.helperText}>
<Trans>Provide at least a user ID or a user tag.</Trans>
</span>
)
} }
/> };
</>
)}
{state.selectedType === 'guild' && ( const breadcrumbs =
<> state.flowStep === 'complete' ? null : (
<Input <ReportBreadcrumbs
label={t`Guild (Community) ID`} current={state.flowStep}
type="text" hasSelection={Boolean(state.selectedType)}
value={state.formValues.guildId} hasEmail={Boolean(state.email.trim())}
onChange={(e) => dispatch({type: 'SET_FORM_FIELD', field: 'guildId', value: e.target.value})} hasTicket={Boolean(state.ticket)}
placeholder="123456789012345678" onSelect={handleBreadcrumbSelect}
autoComplete="off"
footer={
guildTargetOk ? undefined : (
<span className={styles.helperText}>
<Trans>Guild ID is required.</Trans>
</span>
)
}
/> />
<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 const breadcrumbShell =
label={t`Full Legal Name`} state.flowStep === 'complete' ? null : (
type="text" <div className={styles.breadcrumbShell}>
value={state.formValues.reporterFullName} {breadcrumbs ?? <span className={styles.breadcrumbPlaceholder} aria-hidden="true" />}
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>
</div> </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