diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..c452df76 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: ['https://fluxer.app/donate'] diff --git a/.github/workflows/deploy-admin.yaml b/.github/workflows/deploy-admin.yaml index a6fa039a..ab7edfa0 100644 --- a/.github/workflows/deploy-admin.yaml +++ b/.github/workflows/deploy-admin.yaml @@ -70,6 +70,9 @@ jobs: echo "Deploying commit ${sha}" printf 'DEPLOY_SHA=%s\n' "$sha" >> "$GITHUB_ENV" + - name: Set build timestamp + run: echo "BUILD_TIMESTAMP=$(date -u +%s)" >> "$GITHUB_ENV" + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -89,6 +92,8 @@ jobs: platforms: linux/amd64 cache-from: type=gha,scope=${{ env.CACHE_SCOPE }} cache-to: type=gha,mode=max,scope=${{ env.CACHE_SCOPE }} + build-args: | + BUILD_TIMESTAMP=${{ env.BUILD_TIMESTAMP }} env: DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_RECORD_UPLOAD: false diff --git a/.github/workflows/deploy-marketing.yaml b/.github/workflows/deploy-marketing.yaml index e600531e..b01b5492 100644 --- a/.github/workflows/deploy-marketing.yaml +++ b/.github/workflows/deploy-marketing.yaml @@ -74,6 +74,9 @@ jobs: echo "Deploying commit ${sha}" printf 'DEPLOY_SHA=%s\n' "$sha" >> "$GITHUB_ENV" + - name: Set build timestamp + run: echo "BUILD_TIMESTAMP=$(date -u +%s)" >> "$GITHUB_ENV" + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -93,7 +96,8 @@ jobs: platforms: linux/amd64 cache-from: type=gha,scope=${{ env.CACHE_SCOPE }} cache-to: type=gha,mode=max,scope=${{ env.CACHE_SCOPE }} - build-args: BUILD_TIMESTAMP=${{ github.run_id }} + build-args: | + BUILD_TIMESTAMP=${{ env.BUILD_TIMESTAMP }} env: DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_RECORD_UPLOAD: false diff --git a/README.md b/README.md index 0c0cdcff..a46214c4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,13 @@ -# Fluxer +
+ + + Fluxer logo + +
-Chat that puts you first. Built for friends, groups, and communities. Text, voice, and video. Open source and community-funded. +--- + +Fluxer is an open-source, independent instant messaging and VoIP platform. Built for friends, groups, and communities. > [!NOTE] > Docs are coming very soon! With your help and [donations](https://fluxer.app/donate), the self-hosting and documentation story will get a lot better. diff --git a/fluxer_admin/src/fluxer_admin/api/reports.gleam b/fluxer_admin/src/fluxer_admin/api/reports.gleam index ff3af90a..c9a0706c 100644 --- a/fluxer_admin/src/fluxer_admin/api/reports.gleam +++ b/fluxer_admin/src/fluxer_admin/api/reports.gleam @@ -25,8 +25,11 @@ import gleam/dynamic/decode import gleam/http import gleam/http/request import gleam/httpc +import gleam/int +import gleam/io import gleam/json import gleam/option +import gleam/string pub type Report { Report( @@ -194,10 +197,7 @@ pub fn list_reports( "reported_guild_name", decode.optional(decode.string), ) - use reported_guild_icon_hash <- decode.field( - "reported_guild_icon_hash", - decode.optional(decode.string), - ) + let reported_guild_icon_hash = option.None use reported_guild_invite_code <- decode.field( "reported_guild_invite_code", decode.optional(decode.string), @@ -518,14 +518,15 @@ pub fn get_report_detail( let context_message_decoder = { use id <- decode.field("id", decode.string) - use channel_id <- decode.field( - "channel_id", - decode.optional(decode.string), + use channel_id <- decode.optional_field("channel_id", "", decode.string) + use author_id <- decode.optional_field("author_id", "", decode.string) + use author_username <- decode.optional_field( + "author_username", + "", + decode.string, ) - use author_id <- decode.field("author_id", decode.string) - use author_username <- decode.field("author_username", decode.string) - use content <- decode.field("content", decode.string) - use timestamp <- decode.field("timestamp", decode.string) + use content <- decode.optional_field("content", "", decode.string) + use timestamp <- decode.optional_field("timestamp", "", decode.string) use attachments <- decode.optional_field( "attachments", [], @@ -533,7 +534,7 @@ pub fn get_report_detail( ) decode.success(Message( id: id, - channel_id: option.unwrap(channel_id, ""), + channel_id: channel_id, author_id: author_id, author_username: author_username, content: content, @@ -608,8 +609,9 @@ pub fn get_report_detail( "reported_guild_name", decode.optional(decode.string), ) - use reported_guild_icon_hash <- decode.field( + use reported_guild_icon_hash <- decode.optional_field( "reported_guild_icon_hash", + option.None, decode.optional(decode.string), ) use reported_guild_invite_code <- decode.field( @@ -680,7 +682,15 @@ pub fn get_report_detail( case json.parse(resp.body, report_decoder) { Ok(result) -> Ok(result) - Error(_) -> Error(ServerError) + Error(err) -> { + io.println( + "reports.get_report_detail decode failed: " + <> string.inspect(err) + <> " body=" + <> string.slice(resp.body, 0, 4000), + ) + Error(ServerError) + } } } Ok(resp) if resp.status == 401 -> Error(Unauthorized) @@ -699,7 +709,20 @@ pub fn get_report_detail( Error(Forbidden(message)) } Ok(resp) if resp.status == 404 -> Error(NotFound) - Ok(_resp) -> Error(ServerError) - Error(_) -> Error(NetworkError) + Ok(resp) -> { + io.println( + "reports.get_report_detail unexpected status " + <> int.to_string(resp.status) + <> " body=" + <> string.slice(resp.body, 0, 1000), + ) + Error(ServerError) + } + Error(err) -> { + io.println( + "reports.get_report_detail network error: " <> string.inspect(err), + ) + Error(NetworkError) + } } } diff --git a/fluxer_admin/src/fluxer_admin/components/layout.gleam b/fluxer_admin/src/fluxer_admin/components/layout.gleam index ece09430..f48b17f2 100644 --- a/fluxer_admin/src/fluxer_admin/components/layout.gleam +++ b/fluxer_admin/src/fluxer_admin/components/layout.gleam @@ -100,21 +100,36 @@ pub fn page_with_refresh( [a.attribute("lang", "en"), a.attribute("data-base-path", ctx.base_path)], [ build_head_with_refresh(title, ctx, auto_refresh), - h.body([a.class("min-h-screen bg-neutral-50 flex")], [ - sidebar(ctx, active_page), - h.div([a.class("ml-64 flex-1 flex flex-col")], [ - header(ctx, session, current_admin), - h.main([a.class("flex-1 p-8")], [ - h.div([a.class("max-w-7xl mx-auto")], [ - case flash_data { - option.Some(_) -> - h.div([a.class("mb-6")], [flash.view(flash_data)]) - option.None -> element.none() - }, - content, - ]), - ]), + h.body([a.class("min-h-screen bg-neutral-50 overflow-hidden")], [ + h.div([a.class("flex h-screen")], [ + sidebar(ctx, active_page), + h.div( + [ + a.attribute("data-sidebar-overlay", ""), + a.class("fixed inset-0 bg-black/50 z-30 hidden lg:hidden"), + ], + [], + ), + h.div( + [ + a.class("flex-1 flex flex-col w-full h-screen overflow-y-auto"), + ], + [ + header(ctx, session, current_admin), + h.main([a.class("flex-1 p-4 sm:p-6 lg:p-8")], [ + h.div([a.class("w-full max-w-7xl mx-auto")], [ + case flash_data { + option.Some(_) -> + h.div([a.class("mb-6")], [flash.view(flash_data)]) + option.None -> element.none() + }, + content, + ]), + ]), + ], + ), ]), + sidebar_interaction_script(), ]), ], ) @@ -123,18 +138,37 @@ pub fn page_with_refresh( fn sidebar(ctx: Context, active_page: String) { h.div( [ + a.attribute("data-sidebar", ""), a.class( - "w-64 bg-neutral-900 text-white flex flex-col h-screen fixed left-0 top-0", + "fixed inset-y-0 left-0 z-40 w-64 h-screen bg-neutral-900 text-white flex flex-col transform -translate-x-full transition-transform duration-200 ease-in-out lg:translate-x-0 lg:static lg:inset-auto shadow-xl lg:shadow-none", ), ], [ - h.div([a.class("p-6 border-b border-neutral-800")], [ - h.a([href(ctx, "/users")], [ - h.h1([a.class("text-base font-semibold")], [ - element.text("Fluxer Admin"), + h.div( + [ + a.class( + "p-6 border-b border-neutral-800 flex items-center justify-between gap-3", + ), + ], + [ + h.a([href(ctx, "/users")], [ + h.h1([a.class("text-base font-semibold")], [ + element.text("Fluxer Admin"), + ]), ]), - ]), - ]), + h.button( + [ + a.type_("button"), + a.attribute("data-sidebar-close", ""), + a.class( + "lg:hidden inline-flex items-center justify-center p-2 rounded-md text-neutral-200 hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-white/40", + ), + a.attribute("aria-label", "Close sidebar"), + ], + [element.text("Close")], + ), + ], + ), h.nav( [ a.class("flex-1 overflow-y-auto p-4 space-y-1 sidebar-scrollbar"), @@ -304,11 +338,68 @@ fn header( h.header( [ a.class( - "bg-white border-b border-neutral-200 px-8 py-4 flex items-center justify-between", + "bg-white border-b border-neutral-200 px-4 sm:px-6 lg:px-8 py-4 flex items-center justify-between gap-4 sticky top-0 z-10", ), ], [ - render_user_info(ctx, session, current_admin), + h.div([a.class("flex items-center gap-3 min-w-0")], [ + h.button( + [ + a.type_("button"), + a.attribute("data-sidebar-toggle", ""), + a.class( + "lg:hidden inline-flex items-center justify-center p-2 rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-100 focus:outline-none focus:ring-2 focus:ring-neutral-400", + ), + a.attribute("aria-label", "Toggle sidebar"), + ], + [ + element.element( + "svg", + [ + a.attribute("xmlns", "http://www.w3.org/2000/svg"), + a.attribute("viewBox", "0 0 24 24"), + a.class("w-5 h-5"), + a.attribute("fill", "none"), + a.attribute("stroke", "currentColor"), + a.attribute("stroke-width", "2"), + ], + [ + element.element( + "line", + [ + a.attribute("x1", "3"), + a.attribute("y1", "6"), + a.attribute("x2", "21"), + a.attribute("y2", "6"), + ], + [], + ), + element.element( + "line", + [ + a.attribute("x1", "3"), + a.attribute("y1", "12"), + a.attribute("x2", "21"), + a.attribute("y2", "12"), + ], + [], + ), + element.element( + "line", + [ + a.attribute("x1", "3"), + a.attribute("y1", "18"), + a.attribute("x2", "21"), + a.attribute("y2", "18"), + ], + [], + ), + ], + ), + ], + ), + render_user_info(ctx, session, current_admin), + ]), h.a( [ href(ctx, "/logout"), @@ -390,3 +481,58 @@ fn render_avatar( a.class("w-10 h-10 rounded-full"), ]) } + +fn sidebar_interaction_script() { + h.script( + [a.attribute("defer", "defer")], + " +(function() { + const sidebar = document.querySelector('[data-sidebar]'); + const overlay = document.querySelector('[data-sidebar-overlay]'); + const toggles = document.querySelectorAll('[data-sidebar-toggle]'); + const closes = document.querySelectorAll('[data-sidebar-close]'); + if (!sidebar || !overlay) return; + + const open = () => { + sidebar.classList.remove('-translate-x-full'); + overlay.classList.remove('hidden'); + document.body.classList.add('overflow-hidden'); + }; + + const close = () => { + sidebar.classList.add('-translate-x-full'); + overlay.classList.add('hidden'); + document.body.classList.remove('overflow-hidden'); + }; + + toggles.forEach((btn) => btn.addEventListener('click', () => { + if (sidebar.classList.contains('-translate-x-full')) { + open(); + } else { + close(); + } + })); + + closes.forEach((btn) => btn.addEventListener('click', close)); + overlay.addEventListener('click', close); + + window.addEventListener('keydown', (event) => { + if (event.key === 'Escape') close(); + }); + + const syncForDesktop = () => { + if (window.innerWidth >= 1024) { + overlay.classList.add('hidden'); + document.body.classList.remove('overflow-hidden'); + sidebar.classList.remove('-translate-x-full'); + } else { + sidebar.classList.add('-translate-x-full'); + } + }; + + window.addEventListener('resize', syncForDesktop); + syncForDesktop(); +})(); + ", + ) +} diff --git a/fluxer_admin/src/fluxer_admin/components/tabs.gleam b/fluxer_admin/src/fluxer_admin/components/tabs.gleam index 155ce460..4757478b 100644 --- a/fluxer_admin/src/fluxer_admin/components/tabs.gleam +++ b/fluxer_admin/src/fluxer_admin/components/tabs.gleam @@ -28,7 +28,7 @@ pub type Tab { pub fn render_tabs(ctx: Context, tabs: List(Tab)) -> element.Element(a) { h.div([a.class("border-b border-neutral-200 mb-6")], [ h.nav( - [a.class("flex gap-6")], + [a.class("flex gap-6 overflow-x-auto no-scrollbar -mb-px px-1")], list.map(tabs, fn(tab) { render_tab(ctx, tab) }), ), ]) @@ -36,9 +36,10 @@ pub fn render_tabs(ctx: Context, tabs: List(Tab)) -> element.Element(a) { fn render_tab(ctx: Context, tab: Tab) -> element.Element(a) { let class_active = case tab.active { - True -> "border-b-2 border-neutral-900 text-neutral-900 text-sm pb-3" + True -> + "border-b-2 border-neutral-900 text-neutral-900 text-sm pb-3 whitespace-nowrap" False -> - "border-b-2 border-transparent text-neutral-600 hover:text-neutral-900 hover:border-neutral-300 text-sm pb-3 transition-colors" + "border-b-2 border-transparent text-neutral-600 hover:text-neutral-900 hover:border-neutral-300 text-sm pb-3 transition-colors whitespace-nowrap" } h.a([href(ctx, tab.path), a.class(class_active)], [element.text(tab.label)]) diff --git a/fluxer_admin/src/fluxer_admin/components/ui.gleam b/fluxer_admin/src/fluxer_admin/components/ui.gleam index 48e12028..47e522dc 100644 --- a/fluxer_admin/src/fluxer_admin/components/ui.gleam +++ b/fluxer_admin/src/fluxer_admin/components/ui.gleam @@ -23,7 +23,7 @@ import lustre/attribute as a import lustre/element import lustre/element/html as h -pub const table_container_class = "bg-white border border-neutral-200 rounded-lg overflow-hidden" +pub const table_container_class = "bg-white border border-neutral-200 rounded-lg overflow-hidden overflow-x-auto" pub const table_header_cell_class = "px-6 py-3 text-left text-xs text-neutral-600 uppercase tracking-wider" @@ -469,7 +469,10 @@ pub fn flex_row( pub fn flex_row_between( children: List(element.Element(a)), ) -> element.Element(a) { - h.div([a.class("mb-6 flex items-center justify-between")], children) + h.div( + [a.class("mb-6 flex flex-wrap items-center justify-between gap-3")], + children, + ) } pub fn stack( @@ -570,35 +573,37 @@ pub fn data_table( h.div( [a.class("bg-white border border-neutral-200 rounded-lg overflow-hidden")], [ - h.table([a.class("min-w-full divide-y divide-neutral-200")], [ - h.thead([a.class("bg-neutral-50")], [ - h.tr( - [], - list.map(columns, fn(col) { - let TableColumn(header, _, _) = col - h.th( - [ - a.class( - "px-6 py-3 text-left text-xs text-neutral-600 uppercase tracking-wider", - ), - ], - [element.text(header)], + h.div([a.class("overflow-x-auto")], [ + h.table([a.class("min-w-full divide-y divide-neutral-200")], [ + h.thead([a.class("bg-neutral-50")], [ + h.tr( + [], + list.map(columns, fn(col) { + let TableColumn(header, _, _) = col + h.th( + [ + a.class( + "px-6 py-3 text-left text-xs text-neutral-600 uppercase tracking-wider", + ), + ], + [element.text(header)], + ) + }), + ), + ]), + h.tbody( + [a.class("bg-white divide-y divide-neutral-200")], + list.map(rows, fn(row) { + h.tr( + [a.class("hover:bg-neutral-50 transition-colors")], + list.map(columns, fn(col) { + let TableColumn(_, cell_class, render) = col + h.td([a.class(cell_class)], [render(row)]) + }), ) }), ), ]), - h.tbody( - [a.class("bg-white divide-y divide-neutral-200")], - list.map(rows, fn(row) { - h.tr( - [a.class("hover:bg-neutral-50 transition-colors")], - list.map(columns, fn(col) { - let TableColumn(_, cell_class, render) = col - h.td([a.class(cell_class)], [render(row)]) - }), - ) - }), - ), ]), ], ) @@ -629,7 +634,7 @@ pub fn custom_checkbox( ] } - h.label([a.class("flex items-center gap-3 cursor-pointer group")], [ + h.label([a.class("flex items-center gap-3 cursor-pointer group w-full")], [ h.input(checkbox_attrs), element.element( "svg", @@ -637,7 +642,7 @@ pub fn custom_checkbox( a.attribute("xmlns", "http://www.w3.org/2000/svg"), a.attribute("viewBox", "0 0 256 256"), a.class( - "w-5 h-5 bg-white border-2 border-neutral-300 rounded p-0.5 text-white peer-checked:bg-neutral-900 peer-checked:border-neutral-900 transition-colors", + "w-5 h-5 bg-white border-2 border-neutral-300 rounded p-0.5 text-white peer-checked:bg-neutral-900 peer-checked:border-neutral-900 transition-colors flex-shrink-0", ), ], [ @@ -655,8 +660,15 @@ pub fn custom_checkbox( ), ], ), - h.span([a.class("text-sm text-neutral-900 group-hover:text-neutral-700")], [ - element.text(label), + h.div([a.class("flex-1 min-w-0")], [ + h.span( + [ + a.class( + "text-sm text-neutral-900 group-hover:text-neutral-700 leading-snug truncate", + ), + ], + [element.text(label)], + ), ]), ]) } diff --git a/fluxer_admin/src/fluxer_admin/constants.gleam b/fluxer_admin/src/fluxer_admin/constants.gleam index 74403bf4..37f9c9b8 100644 --- a/fluxer_admin/src/fluxer_admin/constants.gleam +++ b/fluxer_admin/src/fluxer_admin/constants.gleam @@ -259,6 +259,10 @@ pub const acl_user_delete = "user:delete" pub const acl_user_cancel_bulk_message_deletion = "user:cancel:bulk_message_deletion" +pub const acl_pending_verification_view = "pending_verification:view" + +pub const acl_pending_verification_review = "pending_verification:review" + pub const acl_beta_codes_generate = "beta_codes:generate" pub const acl_gift_codes_generate = "gift_codes:generate" @@ -506,6 +510,8 @@ pub fn get_all_acls() -> List(String) { acl_user_disable_suspicious, acl_user_delete, acl_user_cancel_bulk_message_deletion, + acl_pending_verification_view, + acl_pending_verification_review, acl_beta_codes_generate, acl_gift_codes_generate, acl_guild_lookup, diff --git a/fluxer_admin/src/fluxer_admin/pages/guild_detail_page.gleam b/fluxer_admin/src/fluxer_admin/pages/guild_detail_page.gleam index 2045050d..98dd4cd9 100644 --- a/fluxer_admin/src/fluxer_admin/pages/guild_detail_page.gleam +++ b/fluxer_admin/src/fluxer_admin/pages/guild_detail_page.gleam @@ -118,60 +118,81 @@ pub fn view( h.div( [a.class("bg-white border border-neutral-200 rounded-lg p-6 mb-6")], [ - h.div([a.class("flex items-start gap-6")], [ - case - avatar.get_guild_icon_url( - ctx.media_endpoint, - guild_data.id, - guild_data.icon, - True, - ) - { - option.Some(icon_url) -> - h.div([a.class("flex-shrink-0")], [ - h.img([ - a.src(icon_url), - a.alt(guild_data.name), - a.class("w-24 h-24 rounded-full"), - ]), - ]) - option.None -> - h.div([a.class("flex-shrink-0")], [ + h.div( + [ + a.class( + "flex flex-col gap-4 sm:flex-row sm:items-start sm:gap-6", + ), + ], + [ + case + avatar.get_guild_icon_url( + ctx.media_endpoint, + guild_data.id, + guild_data.icon, + True, + ) + { + option.Some(icon_url) -> h.div( [ a.class( - "w-24 h-24 rounded-full bg-neutral-200 flex items-center justify-center text-base font-semibold text-neutral-600", + "flex-shrink-0 flex items-center sm:block justify-center", ), ], [ - element.text(avatar.get_initials_from_name( - guild_data.name, - )), + h.img([ + a.src(icon_url), + a.alt(guild_data.name), + a.class("w-24 h-24 rounded-full"), + ]), ], - ), - ]) - }, - ui.detail_header(guild_data.name, [ - #( - "Guild ID:", - h.div([a.class("text-sm text-neutral-900")], [ - element.text(guild_data.id), - ]), - ), - #( - "Owner ID:", - h.a( - [ - href(ctx, "/users/" <> guild_data.owner_id), - a.class( - "text-sm text-neutral-900 hover:text-blue-600 hover:underline", - ), - ], - [element.text(guild_data.owner_id)], + ) + option.None -> + h.div( + [ + a.class( + "flex-shrink-0 flex items-center sm:block justify-center", + ), + ], + [ + h.div( + [ + a.class( + "w-24 h-24 rounded-full bg-neutral-200 flex items-center justify-center text-base font-semibold text-neutral-600", + ), + ], + [ + element.text(avatar.get_initials_from_name( + guild_data.name, + )), + ], + ), + ], + ) + }, + ui.detail_header(guild_data.name, [ + #( + "Guild ID:", + h.div([a.class("text-sm text-neutral-900 break-all")], [ + element.text(guild_data.id), + ]), ), - ), - ]), - ]), + #( + "Owner ID:", + h.a( + [ + href(ctx, "/users/" <> guild_data.owner_id), + a.class( + "text-sm text-neutral-900 hover:text-blue-600 hover:underline", + ), + ], + [element.text(guild_data.owner_id)], + ), + ), + ]), + ], + ), ], ), render_tabs( diff --git a/fluxer_admin/src/fluxer_admin/pages/guilds_page.gleam b/fluxer_admin/src/fluxer_admin/pages/guilds_page.gleam index a73e6dec..d3340663 100644 --- a/fluxer_admin/src/fluxer_admin/pages/guilds_page.gleam +++ b/fluxer_admin/src/fluxer_admin/pages/guilds_page.gleam @@ -112,7 +112,7 @@ pub fn view( fn render_search_form(ctx: Context, query: option.Option(String)) { ui.card(ui.PaddingSmall, [ h.form([a.method("get"), a.class("flex flex-col gap-4")], [ - h.div([a.class("flex gap-2")], [ + h.div([a.class("flex flex-col sm:flex-row gap-2")], [ h.input([ a.type_("text"), a.name("q"), @@ -123,12 +123,12 @@ fn render_search_form(ctx: Context, query: option.Option(String)) { ), a.attribute("autocomplete", "off"), ]), - ui.button_primary("Search", "submit", []), + ui.button_primary("Search", "submit", [a.class("w-full sm:w-auto")]), h.a( [ href(ctx, "/guilds"), a.class( - "px-4 py-2 bg-white text-neutral-700 border border-neutral-300 rounded-lg text-sm font-medium hover:bg-neutral-50 transition-colors", + "px-4 py-2 bg-white text-neutral-700 border border-neutral-300 rounded-lg text-sm font-medium hover:bg-neutral-50 transition-colors w-full sm:w-auto text-center", ), ], [element.text("Clear")], @@ -159,7 +159,7 @@ fn render_guild_card(ctx: Context, guild: guilds.GuildSearchResult) { ], [ h.div([a.class("p-5")], [ - h.div([a.class("flex items-center gap-4")], [ + h.div([a.class("flex flex-col sm:flex-row sm:items-center gap-4")], [ case avatar.get_guild_icon_url( ctx.media_endpoint, @@ -169,27 +169,41 @@ fn render_guild_card(ctx: Context, guild: guilds.GuildSearchResult) { ) { option.Some(icon_url) -> - h.div([a.class("flex-shrink-0")], [ - h.img([ - a.src(icon_url), - a.alt(guild.name), - a.class("w-16 h-16 rounded-full"), - ]), - ]) + h.div( + [ + a.class( + "flex-shrink-0 flex items-center sm:block justify-center", + ), + ], + [ + h.img([ + a.src(icon_url), + a.alt(guild.name), + a.class("w-16 h-16 rounded-full"), + ]), + ], + ) option.None -> - h.div([a.class("flex-shrink-0")], [ - h.div( - [ - a.class( - "w-16 h-16 rounded-full bg-neutral-200 flex items-center justify-center text-base font-medium text-neutral-600", - ), - ], - [element.text(avatar.get_initials_from_name(guild.name))], - ), - ]) + h.div( + [ + a.class( + "flex-shrink-0 flex items-center sm:block justify-center", + ), + ], + [ + h.div( + [ + a.class( + "w-16 h-16 rounded-full bg-neutral-200 flex items-center justify-center text-base font-medium text-neutral-600", + ), + ], + [element.text(avatar.get_initials_from_name(guild.name))], + ), + ], + ) }, h.div([a.class("flex-1 min-w-0")], [ - h.div([a.class("flex items-center gap-2 mb-2")], [ + h.div([a.class("flex items-center gap-2 mb-2 flex-wrap")], [ h.h2([a.class("text-base font-medium text-neutral-900")], [ element.text(guild.name), ]), @@ -207,7 +221,7 @@ fn render_guild_card(ctx: Context, guild: guilds.GuildSearchResult) { }, ]), h.div([a.class("space-y-0.5")], [ - h.div([a.class("text-sm text-neutral-600")], [ + h.div([a.class("text-sm text-neutral-600 break-all")], [ element.text("ID: " <> guild.id), ]), h.div([a.class("text-sm text-neutral-600")], [ diff --git a/fluxer_admin/src/fluxer_admin/pages/pending_verifications_page.gleam b/fluxer_admin/src/fluxer_admin/pages/pending_verifications_page.gleam index 348bf1f7..ef040ac5 100644 --- a/fluxer_admin/src/fluxer_admin/pages/pending_verifications_page.gleam +++ b/fluxer_admin/src/fluxer_admin/pages/pending_verifications_page.gleam @@ -15,18 +15,16 @@ //// You should have received a copy of the GNU Affero General Public License //// along with Fluxer. If not, see . +import fluxer_admin/acl import fluxer_admin/api/common import fluxer_admin/api/verifications import fluxer_admin/avatar import fluxer_admin/components/flash import fluxer_admin/components/layout -import fluxer_admin/components/review_deck -import fluxer_admin/components/review_hintbar import fluxer_admin/components/ui +import fluxer_admin/constants import fluxer_admin/user -import fluxer_admin/web.{ - type Context, type Session, action, href, prepend_base_path, -} +import fluxer_admin/web.{type Context, type Session, action, href} import gleam/int import gleam/list import gleam/option @@ -47,6 +45,60 @@ const suspicious_user_agent_keywords = [ "go-http-client", ] +fn selection_toolbar() -> element.Element(a) { + h.div( + [ + a.class( + "mt-4 flex items-center justify-between gap-3 bg-neutral-50 border border-neutral-200 rounded-lg px-3 py-2", + ), + a.attribute("data-selection-toolbar", "true"), + ], + [ + h.div([a.class("flex items-center gap-3")], [ + h.input([ + a.type_("checkbox"), + a.attribute("data-select-all", "true"), + a.class("h-4 w-4 rounded border-neutral-300"), + ]), + h.span([a.class("text-sm text-neutral-700")], [ + element.text("Select all visible"), + ]), + ]), + h.div([a.class("flex items-center gap-3 flex-wrap justify-end")], [ + h.span( + [ + a.attribute("data-selected-count", "true"), + a.class("text-sm text-neutral-600"), + ], + [element.text("0 selected")], + ), + h.div([a.class("flex items-center gap-2")], [ + h.button( + [ + a.attribute("data-bulk-action", "reject"), + a.class( + "px-3 py-1.5 bg-red-600 text-white rounded-lg text-sm disabled:opacity-40 disabled:cursor-not-allowed", + ), + a.disabled(True), + ], + [element.text("Reject selected")], + ), + h.button( + [ + a.attribute("data-bulk-action", "approve"), + a.class( + "px-3 py-1.5 bg-green-600 text-white rounded-lg text-sm disabled:opacity-40 disabled:cursor-not-allowed", + ), + a.disabled(True), + ], + [element.text("Approve selected")], + ), + ]), + ]), + ], + ) +} + pub fn view( ctx: Context, session: Session, @@ -55,6 +107,12 @@ pub fn view( ) -> Response { let limit = 50 let result = verifications.list_pending_verifications(ctx, session, limit) + let admin_acls = case current_admin { + option.Some(admin) -> admin.acls + option.None -> [] + } + let can_review = + acl.has_permission(admin_acls, constants.acl_pending_verification_review) let content = case result { Ok(response) -> { @@ -75,72 +133,31 @@ pub fn view( h.span( [ a.class("body-sm text-neutral-600"), - a.attribute("data-review-progress", ""), - ], - [ - element.text(int.to_string(count) <> " remaining"), + a.attribute("data-remaining-total", "true"), ], + [element.text(int.to_string(count) <> " remaining")], ) }, ]), ]), + case can_review { + True -> selection_toolbar() + False -> element.none() + }, case list.is_empty(response.pending_verifications) { True -> empty_state() False -> - h.div( - [a.class("mt-4")], - list.append( - [review_deck.styles()], - list.append(review_deck.script_tags(), [ - h.div( - [ - a.attribute("data-review-deck", "true"), - a.attribute( - "data-fragment-base", - prepend_base_path( - ctx, - "/pending-verifications/fragment", - ), - ), - a.attribute("data-next-page", "2"), - a.attribute("data-can-paginate", "true"), - a.attribute("data-prefetch-when-remaining", "6"), - a.attribute( - "data-empty-url", - prepend_base_path(ctx, "/pending-verifications"), - ), - a.tabindex(0), - ], - [ - h.div( - [a.class("max-w-2xl mx-auto")], - list.map(response.pending_verifications, fn(pv) { - render_pending_verification_card(ctx, pv) - }), - ), - h.div( - [ - a.attribute("data-review-progress", "true"), - a.class("text-center mt-4 body-sm text-neutral-600"), - ], - [ - element.text(int.to_string(total) <> " remaining"), - ], - ), - review_hintbar.view( - "←", - "Reject", - "→", - "Approve", - "Esc", - "Exit", - option.Some("Swipe cards on touch devices"), - ), - ], - ), - ]), + h.div([a.class("mt-4")], [ + h.div( + [ + a.class("grid gap-4 md:grid-cols-2 xl:grid-cols-3"), + a.attribute("data-select-grid", "true"), + ], + list.map(response.pending_verifications, fn(pv) { + render_pending_verification_card(ctx, pv, can_review) + }), ), - ) + ]) }, ], ) @@ -156,12 +173,17 @@ pub fn view( session, current_admin, flash_data, - content, + h.div([], [content, pending_verifications_script()]), ) wisp.html_response(element.to_document_string(html), 200) } -pub fn view_fragment(ctx: Context, session: Session, page: Int) -> Response { +pub fn view_fragment( + ctx: Context, + session: Session, + page: Int, + can_review: Bool, +) -> Response { let limit = 50 let _offset = page * limit let result = verifications.list_pending_verifications(ctx, session, limit) @@ -170,19 +192,17 @@ pub fn view_fragment(ctx: Context, session: Session, page: Int) -> Response { Ok(response) -> { let fragment = h.div( - [ - a.attribute("data-review-fragment", "true"), - a.attribute("data-page", int.to_string(page)), - ], + [a.class("grid gap-4 md:grid-cols-2 xl:grid-cols-3")], list.map(response.pending_verifications, fn(pv) { - render_pending_verification_card(ctx, pv) + render_pending_verification_card(ctx, pv, can_review) }), ) wisp.html_response(element.to_document_string(fragment), 200) } Error(_) -> { - let empty = h.div([a.attribute("data-review-fragment", "true")], []) + let empty = + h.div([a.class("grid gap-4 md:grid-cols-2 xl:grid-cols-3")], []) wisp.html_response(element.to_document_string(empty), 200) } @@ -198,6 +218,12 @@ pub fn view_single( ) -> Response { let limit = 50 let result = verifications.list_pending_verifications(ctx, session, limit) + let admin_acls = case current_admin { + option.Some(admin) -> admin.acls + option.None -> [] + } + let can_review = + acl.has_permission(admin_acls, constants.acl_pending_verification_review) let content = case result { Ok(response) -> { @@ -224,45 +250,13 @@ pub fn view_single( ), ]), h.div( - [a.class("mt-4")], - list.append( - [review_deck.styles()], - list.append(review_deck.script_tags(), [ - h.div( - [ - a.attribute("data-review-deck", "true"), - a.attribute( - "data-empty-url", - prepend_base_path(ctx, "/pending-verifications"), - ), - a.tabindex(0), - ], - [ - h.div([a.class("max-w-2xl mx-auto")], [ - render_pending_verification_card(ctx, pv), - ]), - h.div( - [ - a.attribute("data-review-progress", "true"), - a.class("text-center mt-4 body-sm text-neutral-600"), - ], - [ - element.text("1 remaining"), - ], - ), - review_hintbar.view( - "←", - "Reject", - "→", - "Approve", - "Esc", - "Exit", - option.Some("Swipe cards on touch devices"), - ), - ], - ), - ]), - ), + [ + a.class("mt-4 max-w-3xl"), + a.attribute("data-select-grid", "true"), + ], + [ + render_pending_verification_card(ctx, pv, can_review), + ], ), ], ) @@ -289,7 +283,7 @@ pub fn view_single( session, current_admin, flash_data, - content, + h.div([], [content, pending_verifications_script()]), ) wisp.html_response(element.to_document_string(html), 200) } @@ -432,23 +426,30 @@ fn api_error_message(err: common.ApiError) -> String { fn render_pending_verification_card( ctx: Context, pv: verifications.PendingVerification, + can_review: Bool, ) -> element.Element(a) { let metadata_warning = user_agent_warning(pv.metadata) + let geoip_hint = geoip_reason_value(pv.metadata) h.div( [ - a.attribute("data-review-card", "true"), - a.attribute( - "data-direct-url", - prepend_base_path(ctx, "/pending-verifications/" <> pv.user_id), - ), a.class( "bg-white border border-neutral-200 rounded-xl shadow-sm p-6 focus:outline-none focus:ring-2 focus:ring-neutral-900", ), a.tabindex(0), + a.attribute("data-select-card", pv.user_id), ], [ h.div([a.class("flex items-start gap-4 mb-6")], [ + case can_review { + True -> + h.input([ + a.type_("checkbox"), + a.class("h-4 w-4 mt-1.5 rounded border-neutral-300"), + a.attribute("data-select-checkbox", pv.user_id), + ]) + False -> element.none() + }, h.img([ a.src(avatar.get_user_avatar_url( ctx.media_endpoint, @@ -487,6 +488,16 @@ fn render_pending_verification_card( element.text("Registered " <> format_timestamp(pv.created_at)), ]), ]), + h.div([a.class("flex flex-col items-end gap-2 ml-auto")], [ + case metadata_warning { + option.Some(msg) -> ui.pill(msg, ui.PillWarning) + option.None -> element.none() + }, + case geoip_hint { + option.Some(hint) -> ui.pill("GeoIP: " <> hint, ui.PillInfo) + option.None -> element.none() + }, + ]), ]), h.details( [ @@ -515,63 +526,68 @@ fn render_pending_verification_card( ]), ], ), - h.form( - [ - a.method("post"), - action(ctx, "/pending-verifications?action=reject"), - a.attribute("data-review-submit", "left"), - a.class("inline-flex w-full"), - ], - [ - h.input([ - a.type_("hidden"), - a.name("user_id"), - a.value(pv.user_id), - ]), - ], - ), - h.form( - [ - a.method("post"), - action(ctx, "/pending-verifications?action=approve"), - a.attribute("data-review-submit", "right"), - a.class("inline-flex w-full"), - ], - [ - h.input([ - a.type_("hidden"), - a.name("user_id"), - a.value(pv.user_id), - ]), - ], - ), - h.div( - [ - a.class( - "flex items-center justify-between gap-4 pt-4 border-t border-neutral-200", - ), - ], - [ - h.button( - [ - a.attribute("data-review-action", "left"), - a.class( - "px-4 py-2 bg-red-600 text-white rounded-lg label hover:bg-red-700 transition-colors", + case can_review { + True -> + h.div([a.class("pt-4 border-t border-neutral-200")], [ + h.div([a.class("flex items-center justify-end gap-2 flex-wrap")], [ + h.form( + [ + a.method("post"), + action(ctx, "/pending-verifications?action=reject"), + a.attribute("data-async", "true"), + a.attribute("data-confirm", "Reject this registration?"), + ], + [ + h.input([ + a.type_("hidden"), + a.name("user_id"), + a.value(pv.user_id), + ]), + h.button( + [ + a.type_("submit"), + a.attribute("data-action-type", "reject"), + a.attribute("accesskey", "r"), + a.class( + "px-4 py-2 bg-red-600 text-white rounded-lg label hover:bg-red-700 transition-colors", + ), + ], + [element.text("Reject")], + ), + ], ), - ], - [element.text("Reject")], - ), - h.button( - [ - a.attribute("data-review-action", "right"), - a.class( - "px-4 py-2 bg-green-600 text-white rounded-lg label hover:bg-green-700 transition-colors", + h.form( + [ + a.method("post"), + action(ctx, "/pending-verifications?action=approve"), + a.attribute("data-async", "true"), + ], + [ + h.input([ + a.type_("hidden"), + a.name("user_id"), + a.value(pv.user_id), + ]), + h.button( + [ + a.type_("submit"), + a.attribute("data-action-type", "approve"), + a.attribute("accesskey", "a"), + a.class( + "px-4 py-2 bg-green-600 text-white rounded-lg label hover:bg-green-700 transition-colors", + ), + ], + [element.text("Approve")], + ), + ], ), - ], - [element.text("Approve")], - ), - ], - ), + ]), + ]) + False -> + h.div([a.class("text-sm text-neutral-500 pt-2")], [ + element.text("You need pending_verification:review to act"), + ]) + }, ], ) } @@ -689,6 +705,19 @@ fn metadata_value( }) } +fn geoip_reason_value( + metadata: List(verifications.PendingVerificationMetadata), +) -> option.Option(String) { + case metadata_value(metadata, "geoip_reason") { + option.Some(reason) -> + case reason { + "none" -> option.None + r -> option.Some(r) + } + option.None -> option.None + } +} + fn option_or_default(default: String, value: option.Option(String)) -> String { case value { option.Some(v) -> v @@ -737,6 +766,190 @@ fn empty_state() { ]) } +fn pending_verifications_script() -> element.Element(a) { + let js = + " +(function () { + const grid = document.querySelector('[data-select-grid]'); + if (!grid) return; + const toolbar = document.querySelector('[data-selection-toolbar]'); + const remainingEl = document.querySelector('[data-remaining-total]'); + + const countEl = toolbar?.querySelector('[data-selected-count]') || null; + const selectAll = toolbar?.querySelector('[data-select-all]') || null; + const bulkButtons = toolbar + ? Array.from(toolbar.querySelectorAll('[data-bulk-action]')) + : []; + + function showToast(message, variant) { + const box = document.createElement('div'); + box.className = 'fixed left-4 right-4 bottom-4 z-50'; + box.innerHTML = + '
' + + '
' + + '
' + + (variant === 'success' ? 'Saved' : 'Action failed') + + '
' + + '
' + (message || 'OK') + '
' + + '
'; + 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 = + '
All registration requests have been processed
'; + } + } + + function setButtonLoading(btn, loading) { + if (!btn) return; + btn.disabled = loading; + if (loading) { + btn.dataset.originalText = btn.textContent; + btn.textContent = 'Working…'; + } else if (btn.dataset.originalText) { + btn.textContent = btn.dataset.originalText; + } + } + + async function submitForm(form) { + const actionUrl = new URL(form.action, window.location.origin); + actionUrl.searchParams.set('background', '1'); + const fd = new FormData(form); + const body = new URLSearchParams(); + fd.forEach((v, k) => body.append(k, v)); + const resp = await fetch(actionUrl.toString(), { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded;charset=UTF-8' }, + body: body.toString(), + credentials: 'same-origin', + }); + if (!resp.ok && resp.status !== 204) { + let txt = ''; + try { txt = await resp.text(); } catch (_) {} + throw new Error(txt || 'Request failed (' + resp.status + ')'); + } + } + + async function actOn(id, action) { + const form = grid.querySelector( + '[data-select-card=\"' + id + '\"] form[data-action-type=\"' + action + '\"]' + ); + if (!form) throw new Error('Missing form for ' + action); + await submitForm(form); + removeCard(id); + } + + async function handleBulk(action) { + const boxes = Array.from(grid.querySelectorAll('[data-select-checkbox]:checked')); + if (boxes.length === 0) return; + const confirmMsg = + action === 'reject' + ? 'Reject ' + boxes.length + ' registration(s)?' + : 'Approve ' + boxes.length + ' registration(s)?'; + if (!window.confirm(confirmMsg)) return; + + const button = toolbar.querySelector('[data-bulk-action=\"' + action + '\"]'); + setButtonLoading(button, true); + try { + for (const box of boxes) { + const id = box.getAttribute('data-select-checkbox'); + if (!id) continue; + await actOn(id, action); + } + showToast('Completed ' + action + ' for ' + boxes.length + ' item(s)', 'success'); + } catch (err) { + showToast(err && err.message ? err.message : String(err), 'error'); + } finally { + setButtonLoading(button, false); + } + } + + bulkButtons.forEach((btn) => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + const action = btn.getAttribute('data-bulk-action'); + if (action) handleBulk(action); + }); + }); + + if (selectAll) { + selectAll.addEventListener('change', (e) => { + const boxes = grid.querySelectorAll('[data-select-checkbox]'); + boxes.forEach((b) => (b.checked = e.target.checked)); + updateSelection(); + }); + } + + grid.addEventListener('change', (e) => { + const target = e.target; + if (target && target.matches('[data-select-checkbox]')) { + updateSelection(); + } + }); + + document.querySelectorAll('form[data-async]').forEach((form) => { + form.addEventListener('submit', (e) => { + e.preventDefault(); + const confirmMsg = form.getAttribute('data-confirm'); + if (confirmMsg && !window.confirm(confirmMsg)) return; + const btn = form.querySelector('button[type=\"submit\"]'); + const id = form.querySelector('[name=\"user_id\"]')?.value; + const action = btn?.getAttribute('data-action-type') || 'action'; + setButtonLoading(btn, true); + submitForm(form) + .then(() => { + if (id) removeCard(id); + showToast( + (action === 'approve' ? 'Approved ' : 'Rejected ') + (id || 'item'), + 'success' + ); + }) + .catch((err) => showToast(err && err.message ? err.message : String(err), 'error')) + .finally(() => setButtonLoading(btn, false)); + }); + }); + + updateRemaining(); + updateSelection(); +})(); +" + + h.script([a.attribute("defer", "defer")], js) +} + fn error_view(err: common.ApiError) { let #(title, message) = case err { common.Unauthorized -> #( diff --git a/fluxer_admin/src/fluxer_admin/pages/reports_page.gleam b/fluxer_admin/src/fluxer_admin/pages/reports_page.gleam index b9828cd8..84611f7e 100644 --- a/fluxer_admin/src/fluxer_admin/pages/reports_page.gleam +++ b/fluxer_admin/src/fluxer_admin/pages/reports_page.gleam @@ -20,13 +20,12 @@ import fluxer_admin/api/reports import fluxer_admin/components/date_time import fluxer_admin/components/flash import fluxer_admin/components/layout -import fluxer_admin/components/review_deck -import fluxer_admin/components/review_hintbar import fluxer_admin/components/ui import fluxer_admin/web.{type Context, type Session, href} import gleam/int import gleam/list import gleam/option +import gleam/order import gleam/string import gleam/uri import lustre/attribute as a @@ -55,6 +54,95 @@ const report_category_options = [ #("extremist_community", "Extremist Community"), ] +fn sort_option( + value: String, + label: String, + current: option.Option(String), +) -> element.Element(a) { + h.option([a.value(value), a.selected(current == option.Some(value))], label) +} + +fn limit_option(value: Int, current: Int) -> element.Element(a) { + h.option( + [a.value(int.to_string(value)), a.selected(value == current)], + int.to_string(value), + ) +} + +fn quick_filter_chip( + ctx: Context, + label: String, + status_filter: option.Option(Int), + type_filter: option.Option(Int), + category_filter: option.Option(String), + query: option.Option(String), + sort: option.Option(String), + limit: Int, +) -> element.Element(a) { + let url = + build_pagination_url( + 0, + query, + status_filter, + type_filter, + category_filter, + sort, + limit, + ) + + h.a( + [ + href(ctx, url), + a.class( + "px-3 py-1.5 bg-neutral-100 text-neutral-700 border border-neutral-200 rounded-full text-sm hover:bg-neutral-200 transition-colors", + ), + ], + [element.text(label)], + ) +} + +fn selection_toolbar() -> element.Element(a) { + h.div( + [ + a.class( + "flex items-center justify-between gap-3 bg-white border border-neutral-200 rounded-lg px-3 py-2 mb-3", + ), + a.attribute("data-report-toolbar", "true"), + ], + [ + h.div([a.class("flex items-center gap-2")], [ + h.input([ + a.type_("checkbox"), + a.class("h-4 w-4 rounded border-neutral-300"), + a.attribute("data-report-select-all", "true"), + ]), + h.span([a.class("text-sm text-neutral-700")], [ + element.text("Select all on this page"), + ]), + ]), + h.div([a.class("flex items-center gap-2 flex-wrap")], [ + h.span( + [ + a.attribute("data-report-selected-count", "true"), + a.class("text-sm text-neutral-600"), + ], + [element.text("0 selected")], + ), + h.button( + [ + a.attribute("data-report-bulk-resolve", "true"), + 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("Resolve selected")], + ), + ]), + ], + ) +} + pub fn view( ctx: Context, session: Session, @@ -65,6 +153,8 @@ pub fn view( type_filter: option.Option(Int), category_filter: option.Option(String), page: Int, + limit: Int, + sort: option.Option(String), ) -> Response { view_with_mode( ctx, @@ -76,59 +166,11 @@ pub fn view( type_filter, category_filter, page, - True, + limit, + sort, ) } -pub fn view_fragment( - ctx: Context, - session: Session, - query: option.Option(String), - status_filter: option.Option(Int), - type_filter: option.Option(Int), - category_filter: option.Option(String), - page: Int, -) -> Response { - let limit = 50 - let offset = page * limit - - let result = - reports.search_reports( - ctx, - session, - query, - status_filter, - type_filter, - category_filter, - limit, - offset, - ) - - let content = case result { - Ok(response) -> { - h.div([a.attribute("data-review-fragment", "true")], [ - h.div( - [a.class("max-w-7xl mx-auto")], - list.map(response.reports, fn(report) { - render_report_card(ctx, report) - }), - ), - ]) - } - Error(err) -> { - h.div( - [ - a.attribute("data-review-fragment", "true"), - a.attribute("data-fragment-error", "true"), - ], - [h.span([], [element.text(api_error_message(err))])], - ) - } - } - - wisp.html_response(element.to_document_string(content), 200) -} - pub fn view_with_mode( ctx: Context, session: Session, @@ -139,9 +181,9 @@ pub fn view_with_mode( type_filter: option.Option(Int), category_filter: option.Option(String), page: Int, - table_view: Bool, + limit: Int, + sort: option.Option(String), ) -> Response { - let limit = 50 let offset = page * limit let result = @@ -158,6 +200,7 @@ pub fn view_with_mode( let content = case result { Ok(response) -> { + let sorted_reports = sort_reports(response.reports, sort) h.div([a.class("max-w-7xl mx-auto")], [ ui.flex_row_between([ ui.heading_page("Reports"), @@ -167,7 +210,7 @@ pub fn view_with_mode( "Found " <> int.to_string(response.total) <> " results (showing " - <> int.to_string(list.length(response.reports)) + <> int.to_string(list.length(sorted_reports)) <> ")", ), ]), @@ -179,41 +222,28 @@ pub fn view_with_mode( status_filter, type_filter, category_filter, - table_view, + sort, + limit, ), case list.is_empty(response.reports) { True -> empty_state() False -> - case table_view { - True -> - h.div([a.class("mt-4")], [ - render_reports_table(ctx, response.reports), - render_pagination( - ctx, - response.total, - response.offset, - response.limit, - page, - query, - status_filter, - type_filter, - category_filter, - ), - ]) - False -> - h.div([a.class("mt-4")], [ - render_review_deck( - ctx, - response.reports, - response.total, - page, - query, - status_filter, - type_filter, - category_filter, - ), - ]) - } + h.div([a.class("mt-4")], [ + selection_toolbar(), + render_reports_table(ctx, sorted_reports), + render_pagination( + ctx, + response.total, + response.offset, + response.limit, + page, + query, + status_filter, + type_filter, + category_filter, + sort, + ), + ]) }, ]) } @@ -228,7 +258,7 @@ pub fn view_with_mode( session, current_admin, flash_data, - content, + h.div([], [content, reports_script()]), ) wisp.html_response(element.to_document_string(html), 200) } @@ -239,10 +269,63 @@ fn render_filters( status_filter: option.Option(Int), type_filter: option.Option(Int), category_filter: option.Option(String), - table_view: Bool, + sort: option.Option(String), + limit: Int, ) { h.div([a.class("bg-white border border-neutral-200 rounded-lg p-4 mb-6")], [ h.form([a.method("get"), a.class("space-y-4")], [ + h.div([a.class("flex flex-wrap gap-2")], [ + quick_filter_chip( + ctx, + "Pending", + option.Some(0), + type_filter, + category_filter, + query, + sort, + limit, + ), + quick_filter_chip( + ctx, + "Resolved", + option.Some(1), + type_filter, + category_filter, + query, + sort, + limit, + ), + quick_filter_chip( + ctx, + "Message", + status_filter, + option.Some(0), + category_filter, + query, + sort, + limit, + ), + quick_filter_chip( + ctx, + "User", + status_filter, + option.Some(1), + category_filter, + query, + sort, + limit, + ), + quick_filter_chip( + ctx, + "Guild", + status_filter, + option.Some(2), + category_filter, + query, + sort, + limit, + ), + ]), h.div([a.class("w-full")], [ h.label([a.class("block body-sm text-neutral-700 mb-2")], [ element.text("Search"), @@ -257,7 +340,7 @@ fn render_filters( ), ]), ]), - h.div([a.class("grid grid-cols-1 md:grid-cols-3 gap-4")], [ + h.div([a.class("grid grid-cols-1 md:grid-cols-4 gap-4")], [ h.div([a.class("flex-1")], [ h.label([a.class("block body-sm text-neutral-700 mb-2")], [ element.text("Status"), @@ -350,6 +433,44 @@ fn render_filters( ), ]) }, + h.div([a.class("flex-1")], [ + h.label([a.class("block body-sm text-neutral-700 mb-2")], [ + element.text("Sort"), + ]), + h.select( + [ + a.name("sort"), + a.class( + "w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:border-transparent", + ), + ], + [ + sort_option("reported_at_desc", "Newest first", sort), + sort_option("reported_at_asc", "Oldest first", sort), + sort_option("status_asc", "Status ↑", sort), + sort_option("status_desc", "Status ↓", sort), + ], + ), + ]), + h.div([a.class("flex-1")], [ + h.label([a.class("block body-sm text-neutral-700 mb-2")], [ + element.text("Page size"), + ]), + h.select( + [ + a.name("limit"), + a.class( + "w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:border-transparent", + ), + ], + [ + limit_option(25, limit), + limit_option(50, limit), + limit_option(100, limit), + limit_option(150, limit), + ], + ), + ]), ]), h.div([a.class("flex gap-2")], [ h.button( @@ -370,54 +491,6 @@ fn render_filters( ], [element.text("Clear")], ), - h.a( - [ - href( - ctx, - build_table_view_url( - query, - status_filter, - type_filter, - category_filter, - False, - ), - ), - a.class( - "px-4 py-2 " - <> case table_view { - True -> - "bg-neutral-100 text-neutral-600 border border-neutral-200" - False -> "bg-white text-neutral-900 border border-neutral-300" - } - <> " rounded-lg label hover:bg-neutral-50 transition-colors", - ), - ], - [element.text("Deck")], - ), - h.a( - [ - href( - ctx, - build_table_view_url( - query, - status_filter, - type_filter, - category_filter, - True, - ), - ), - a.class( - "px-4 py-2 " - <> case table_view { - True -> "bg-white text-neutral-900 border border-neutral-300" - False -> - "bg-neutral-100 text-neutral-600 border border-neutral-200" - } - <> " rounded-lg label hover:bg-neutral-50 transition-colors", - ), - ], - [element.text("Table view")], - ), ]), ]), ]) @@ -427,6 +500,17 @@ fn render_reports_table(ctx: Context, reports: List(reports.SearchReportResult)) let base_cell = ui.table_cell_class let columns = [ + ui.TableColumn( + "", + base_cell <> " w-10", + fn(report: reports.SearchReportResult) { + h.input([ + a.type_("checkbox"), + a.class("h-4 w-4 rounded border-neutral-300"), + a.attribute("data-report-select", report.report_id), + ]) + }, + ), ui.TableColumn( "Reported At", base_cell <> " whitespace-nowrap", @@ -463,7 +547,9 @@ fn render_reports_table(ctx: Context, reports: List(reports.SearchReportResult)) ui.TableColumn( "Status", "px-6 py-4 whitespace-nowrap", - fn(report: reports.SearchReportResult) { status_pill(report.status) }, + fn(report: reports.SearchReportResult) { + status_pill(report.report_id, report.status) + }, ), ui.TableColumn( "Actions", @@ -474,7 +560,9 @@ fn render_reports_table(ctx: Context, reports: List(reports.SearchReportResult)) ), ] - ui.data_table(columns, reports) + h.div([a.attribute("data-report-table", "true")], [ + ui.data_table(columns, reports), + ]) } fn render_reported_cell(ctx: Context, report: reports.SearchReportResult) { @@ -514,22 +602,24 @@ fn render_reporter_cell(ctx: Context, report: reports.SearchReportResult) { h.span([a.class("text-sm text-neutral-900")], [element.text(primary)]) } - let detail_fragments = [] - let detail_fragments = case report.reporter_full_legal_name { - option.Some(full_name) -> - list.append(detail_fragments, [element.text(full_name)]) - option.None -> detail_fragments + let detail_values = [] + let detail_values = case report.reporter_full_legal_name { + option.Some(full_name) -> list.append(detail_values, [full_name]) + option.None -> detail_values } - let detail_fragments = case report.reporter_country_of_residence { - option.Some(country) -> - list.append(detail_fragments, [element.text(country)]) - option.None -> detail_fragments + let detail_values = case report.reporter_country_of_residence { + option.Some(country) -> list.append(detail_values, [country]) + option.None -> detail_values } - let secondary = case list.is_empty(detail_fragments) { + let secondary = case list.is_empty(detail_values) { True -> element.none() - False -> h.div([a.class("text-xs text-neutral-500")], detail_fragments) + False -> + h.div( + [a.class("flex flex-col gap-1 text-xs text-neutral-500")], + list.map(detail_values, fn(value) { h.div([], [element.text(value)]) }), + ) } h.div([a.class("flex flex-col gap-1")], [ @@ -588,6 +678,62 @@ fn render_reported_guild_cell(ctx: Context, report: reports.SearchReportResult) } } +fn compare_reports( + sort_key: String, + a: reports.SearchReportResult, + b: reports.SearchReportResult, +) -> Bool { + case sort_key { + "reported_at_asc" -> + string.compare(a.reported_at, b.reported_at) == order.Lt + "status_asc" -> + case a.status == b.status { + True -> string.compare(a.reported_at, b.reported_at) == order.Lt + False -> a.status < b.status + } + "status_desc" -> + case a.status == b.status { + True -> string.compare(a.reported_at, b.reported_at) == order.Lt + False -> a.status > b.status + } + _ -> + case string.compare(a.reported_at, b.reported_at) { + order.Gt -> True + order.Eq -> + case string.compare(a.report_id, b.report_id) { + order.Lt -> True + _ -> False + } + order.Lt -> False + } + } +} + +fn insert_sorted( + acc: List(reports.SearchReportResult), + item: reports.SearchReportResult, + sort_key: String, +) -> List(reports.SearchReportResult) { + case acc { + [] -> [item] + [head, ..tail] -> + case compare_reports(sort_key, item, head) { + True -> [item, ..acc] + False -> [head, ..insert_sorted(tail, item, sort_key)] + } + } +} + +fn sort_reports( + reports_list: List(reports.SearchReportResult), + sort: option.Option(String), +) -> List(reports.SearchReportResult) { + let sort_key = option.unwrap(sort, "reported_at_desc") + list.fold(reports_list, [], fn(acc, item) { + insert_sorted(acc, item, sort_key) + }) +} + fn format_user_tag(report: reports.SearchReportResult) -> String { case report.reported_user_tag { option.Some(tag) -> tag @@ -605,15 +751,52 @@ fn format_user_tag(report: reports.SearchReportResult) -> String { } fn render_actions_cell(ctx: Context, report: reports.SearchReportResult) { - h.a( - [ - href(ctx, "/reports/" <> report.report_id), - a.class( - "inline-flex items-center px-3 py-1.5 bg-neutral-900 text-white rounded text-xs font-medium hover:bg-neutral-800 transition-colors", + let resolve_button = case report.status == 0 { + True -> + h.form( + [ + a.method("post"), + a.attribute("action", "/reports/" <> report.report_id <> "/resolve"), + a.attribute("data-report-action", "resolve"), + a.attribute("data-report-id", report.report_id), + a.attribute("data-confirm", "Resolve this report?"), + a.attribute("data-async", "true"), + ], + [ + h.input([a.type_("hidden"), a.name("_method"), a.value("post")]), + h.input([ + a.type_("hidden"), + a.name("public_comment"), + a.value("Resolved via reports table"), + ]), + h.button( + [ + a.type_("submit"), + a.class( + "px-3 py-1.5 bg-green-600 text-white rounded text-xs font-medium hover:bg-green-700 transition-colors", + ), + ], + [element.text("Resolve")], + ), + ], + ) + False -> element.none() + } + + h.div([a.class("flex flex-col gap-2")], [ + h.div([a.class("flex flex-wrap gap-2")], [ + h.a( + [ + href(ctx, "/reports/" <> report.report_id), + a.class( + "inline-flex items-center px-3 py-1.5 bg-neutral-900 text-white rounded text-xs font-medium hover:bg-neutral-800 transition-colors", + ), + ], + [element.text("View Details")], ), - ], - [element.text("View Details")], - ) + resolve_button, + ]), + ]) } fn render_pagination( @@ -626,6 +809,7 @@ fn render_pagination( status_filter: option.Option(Int), type_filter: option.Option(Int), category_filter: option.Option(String), + sort: option.Option(String), ) { let total_pages = { total + limit - 1 } / limit let has_previous = current_page > 0 @@ -641,6 +825,8 @@ fn render_pagination( status_filter, type_filter, category_filter, + sort, + limit, ) h.a( @@ -680,6 +866,8 @@ fn render_pagination( status_filter, type_filter, category_filter, + sort, + limit, ) h.a( @@ -711,9 +899,14 @@ fn build_pagination_url( status_filter: option.Option(Int), type_filter: option.Option(Int), category_filter: option.Option(String), + sort: option.Option(String), + limit: Int, ) -> String { let base = "/reports" - let mut_params = [#("page", int.to_string(page))] + let mut_params = [ + #("page", int.to_string(page)), + #("limit", int.to_string(limit)), + ] let mut_params = case query { option.Some(q) -> @@ -743,6 +936,15 @@ fn build_pagination_url( option.None -> mut_params } + let mut_params = case sort { + option.Some(s) -> + case string.trim(s) { + "" -> mut_params + s -> [#("sort", s), ..mut_params] + } + option.None -> mut_params + } + case mut_params { [] -> base params -> { @@ -778,14 +980,16 @@ fn report_type_pill(report_type: Int) { ui.pill(format_report_type(report_type), tone) } -fn status_pill(status: Int) { +fn status_pill(report_id: String, status: Int) { let #(label, tone) = case status { 0 -> #("Pending", ui.PillWarning) 1 -> #("Resolved", ui.PillSuccess) _ -> #("Unknown", ui.PillNeutral) } - ui.pill(label, tone) + h.span([a.attribute("data-status-pill", report_id)], [ + ui.pill(label, tone), + ]) } fn empty_state() { @@ -795,344 +999,163 @@ fn empty_state() { ]) } -fn api_error_message(err: common.ApiError) -> String { - case err { - common.Unauthorized -> "Authentication Required" - common.Forbidden(msg) -> msg - common.NotFound -> "Reports could not be retrieved." - common.ServerError -> - "An internal server error occurred. Please try again later." - common.NetworkError -> - "Could not connect to the API. Please try again later." - } -} +fn reports_script() -> element.Element(a) { + let js = + " +(function () { + const table = document.querySelector('[data-report-table]'); + if (!table) return; + const toolbar = document.querySelector('[data-report-toolbar]'); + const selectAll = toolbar?.querySelector('[data-report-select-all]') || null; + const countEl = toolbar?.querySelector('[data-report-selected-count]') || null; + const bulkBtn = toolbar?.querySelector('[data-report-bulk-resolve]') || null; -fn build_table_view_url( - query: option.Option(String), - status_filter: option.Option(Int), - type_filter: option.Option(Int), - category_filter: option.Option(String), - table_view: Bool, -) -> String { - let base = "/reports" - let mut_params = [ - #("table", case table_view { - True -> "1" - False -> "0" - }), - ] - - let mut_params = case query { - option.Some(q) -> - case string.trim(q) { - "" -> mut_params - q -> [#("q", q), ..mut_params] - } - option.None -> mut_params + function showToast(message, ok) { + const box = document.createElement('div'); + box.className = 'fixed left-4 right-4 bottom-4 z-50'; + box.innerHTML = + '
' + + '
' + + '
' + (ok ? 'Success' : 'Action failed') + '
' + + '
' + (message || (ok ? 'Done' : 'Unknown error')) + '
' + + '
'; + document.body.appendChild(box); + setTimeout(() => box.remove(), 4000); } - let mut_params = case status_filter { - option.Some(s) -> [#("status", int.to_string(s)), ..mut_params] - option.None -> mut_params + function selectionBoxes() { + return Array.from(table.querySelectorAll('[data-report-select]')); } - let mut_params = case type_filter { - option.Some(t) -> [#("type", int.to_string(t)), ..mut_params] - option.None -> mut_params - } - - let mut_params = case category_filter { - option.Some(c) -> - case string.trim(c) { - "" -> mut_params - c -> [#("category", c), ..mut_params] - } - option.None -> mut_params - } - - case mut_params { - [] -> base - params -> { - let query_string = - params - |> list.map(fn(pair) { - let #(key, value) = pair - key <> "=" <> uri.percent_encode(value) - }) - |> string.join("&") - base <> "?" <> query_string + function updateSelection() { + const boxes = selectionBoxes(); + const selected = boxes.filter((b) => b.checked); + if (countEl) countEl.textContent = selected.length + ' selected'; + if (bulkBtn) bulkBtn.disabled = selected.length === 0; + if (selectAll) { + selectAll.checked = selected.length > 0 && selected.length === boxes.length; + selectAll.indeterminate = + selected.length > 0 && selected.length < boxes.length; } } -} -fn prepend_fragment_base(ctx: Context, path: String) -> String { - ctx.base_path <> path -} - -fn render_review_deck( - ctx: Context, - reports: List(reports.SearchReportResult), - _total: Int, - page: Int, - query: option.Option(String), - status_filter: option.Option(Int), - type_filter: option.Option(Int), - category_filter: option.Option(String), -) { - let fragment_base = - prepend_fragment_base( - ctx, - "/reports/fragment?" - <> build_table_view_url( - query, - status_filter, - type_filter, - category_filter, - True, - ), - ) - - let deck_attrs = [ - a.attribute("data-review-deck", "true"), - a.attribute("data-fragment-base", fragment_base), - a.attribute("data-next-page", int.to_string(page + 1)), - a.attribute("data-can-paginate", "true"), - a.attribute("data-empty-url", "/reports"), - a.attribute("data-prefetch-when-remaining", "6"), - a.tabindex(0), - ] - - h.div(deck_attrs, [ - review_deck.styles(), - h.div( - [a.class("max-w-7xl mx-auto")], - list.map(reports, fn(report) { render_report_card(ctx, report) }), - ), - h.div( - [ - a.attribute("data-review-progress", "true"), - a.class("text-center mt-4 body-sm text-neutral-600"), - ], - [ - element.text(int.to_string(list.length(reports)) <> " remaining"), - ], - ), - review_hintbar.view( - "←", - "Skip", - "→", - "Resolve", - "Esc", - "Exit", - option.Some("Swipe cards on touch devices"), - ), - ..review_deck.script_tags() - ]) -} - -fn render_report_card(ctx: Context, report: reports.SearchReportResult) { - let card_attrs = [ - a.attribute("data-review-card", "true"), - a.attribute("data-left-mode", "skip"), - a.attribute("data-direct-url", "/reports/" <> report.report_id), - a.attribute("data-expand-url", "/reports/" <> report.report_id), - a.attribute("data-expand-target", "[data-report-context]"), - a.tabindex(0), - a.class( - "review-card bg-white border border-neutral-200 rounded-xl shadow-sm p-6 mb-4 focus:outline-none focus:ring-2 focus:ring-neutral-900", - ), - ] - - h.div(card_attrs, [ - h.div([a.class("flex items-start justify-between gap-4 mb-4")], [ - h.div([a.class("flex items-center gap-3")], [ - h.a( - [ - href(ctx, "/reports/" <> report.report_id), - a.class("hover:underline"), - ], - [element.text("#" <> report.report_id)], - ), - status_pill(report.status), - report_type_pill(report.report_type), - ]), - h.span([a.class("body-sm text-neutral-500 whitespace-nowrap")], [ - element.text(date_time.format_timestamp(report.reported_at)), - ]), - ]), - h.div([a.class("space-y-2 mb-4")], [ - h.div([a.class("flex items-center gap-2")], [ - h.span([a.class("label-sm text-neutral-600")], [ - element.text("Category:"), - ]), - h.span([a.class("body-sm text-neutral-900")], [ - element.text(report.category), - ]), - ]), - case report.additional_info { - option.Some(info) if info != "" -> - h.div([a.class("flex items-start gap-2")], [ - h.span([a.class("label-sm text-neutral-600")], [ - element.text("Details:"), - ]), - h.span([a.class("body-sm text-neutral-900 flex-1")], [ - element.text(info), - ]), - ]) - _ -> element.none() - }, - ]), - h.div([a.class("grid grid-cols-1 md:grid-cols-2 gap-4 mb-4")], [ - h.div([a.class("space-y-1")], [ - h.div([a.class("label-sm text-neutral-600")], [element.text("Reporter")]), - render_reporter_compact(ctx, report), - ]), - h.div([a.class("space-y-1")], [ - h.div([a.class("label-sm text-neutral-600")], [element.text("Reported")]), - render_reported_compact(ctx, report), - ]), - ]), - h.div([a.attribute("data-report-context", "true"), a.hidden(True)], []), - h.form( - [ - a.attribute("data-review-submit", "left"), - a.method("post"), - a.attribute("action", "/reports/" <> report.report_id <> "/skip"), - ], - [h.input([a.type_("hidden"), a.name("_method"), a.value("post")])], - ), - h.form( - [ - a.attribute("data-review-submit", "right"), - a.method("post"), - a.attribute("action", "/reports/" <> report.report_id <> "/resolve"), - ], - [ - h.input([a.type_("hidden"), a.name("_method"), a.value("post")]), - h.input([ - a.type_("hidden"), - a.name("public_comment"), - a.value("Resolved via review deck"), - ]), - ], - ), - h.div( - [ - a.class( - "flex items-center justify-between pt-4 border-t border-neutral-200", - ), - ], - [ - h.button( - [ - a.attribute("data-review-action", "left"), - a.class( - "px-4 py-2 bg-white text-neutral-700 border border-neutral-300 rounded-lg label hover:bg-neutral-50 transition-colors", - ), - ], - [element.text("Skip")], - ), - h.button( - [ - a.attribute("data-review-action", "right"), - a.class( - "px-4 py-2 bg-green-600 text-white rounded-lg label hover:bg-green-700 transition-colors", - ), - ], - [element.text("Resolve")], - ), - ], - ), - ]) -} - -fn render_reporter_compact(ctx: Context, report: reports.SearchReportResult) { - let primary = case report.reporter_tag { - option.Some(tag) -> tag - option.None -> - case report.reporter_email { - option.Some(email) -> email - option.None -> "Anonymous" - } + function setLoading(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; + } } - let primary_element = case report.reporter_id { - option.Some(id) -> - h.a( - [ - href(ctx, "/users/" <> id), - a.class("body-sm text-neutral-900 hover:text-neutral-600 underline"), - ], - [element.text(primary)], - ) - option.None -> - h.span([a.class("body-sm text-neutral-900")], [element.text(primary)]) + 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 t = ''; + try { t = await resp.text(); } catch (_) {} + throw new Error(t || 'Request failed (' + resp.status + ')'); + } } - primary_element -} - -fn render_reported_compact(ctx: Context, report: reports.SearchReportResult) { - case report.report_type { - 0 -> - case report.reported_user_id { - option.Some(id) -> - h.a( - [ - href(ctx, "/users/" <> id), - a.class( - "body-sm text-neutral-900 hover:text-neutral-600 underline", - ), - ], - [element.text(format_user_tag(report))], - ) - option.None -> - h.span([a.class("body-sm text-neutral-400 italic")], [ - element.text("—"), - ]) - } - 1 -> - case report.reported_user_id { - option.Some(id) -> - h.a( - [ - href(ctx, "/users/" <> id), - a.class( - "body-sm text-neutral-900 hover:text-neutral-600 underline", - ), - ], - [element.text(format_user_tag(report))], - ) - option.None -> - h.span([a.class("body-sm text-neutral-400 italic")], [ - element.text("—"), - ]) - } - 2 -> - case report.reported_guild_id { - option.Some(guild_id) -> { - let primary_name = case report.reported_guild_name { - option.Some(name) -> name - option.None -> "Guild " <> guild_id - } - h.a( - [ - href(ctx, "/guilds/" <> guild_id), - a.class( - "body-sm text-neutral-900 hover:text-neutral-600 underline", - ), - ], - [element.text(primary_name)], - ) - } - option.None -> - h.span([a.class("body-sm text-neutral-400 italic")], [ - element.text("—"), - ]) - } - _ -> - h.span([a.class("body-sm text-neutral-400 italic")], [element.text("—")]) + function markResolved(reportId) { + const pill = table.querySelector('[data-status-pill=\"' + reportId + '\"]'); + if (pill) pill.textContent = 'Resolved'; + const form = table.querySelector('form[data-report-id=\"' + reportId + '\"]'); + if (form) { + form.remove(); + } } + + async function resolveOne(reportId) { + const form = table.querySelector( + 'form[data-report-id=\"' + reportId + '\"][data-report-action=\"resolve\"]' + ); + if (!form) throw new Error('Missing resolve form'); + await submitForm(form); + markResolved(reportId); + } + + async function handleBulkResolve() { + const boxes = selectionBoxes().filter((b) => b.checked); + if (boxes.length === 0) return; + if (!window.confirm('Resolve ' + boxes.length + ' report(s)?')) return; + setLoading(bulkBtn, true); + try { + for (const box of boxes) { + const id = box.getAttribute('data-report-select'); + if (!id) continue; + await resolveOne(id); + box.checked = false; + } + showToast('Resolved ' + boxes.length + ' report(s)', true); + } catch (err) { + showToast(err && err.message ? err.message : String(err), false); + } finally { + setLoading(bulkBtn, false); + updateSelection(); + } + } + + function wireSelection() { + if (selectAll) { + selectAll.addEventListener('change', (e) => { + selectionBoxes().forEach((b) => (b.checked = e.target.checked)); + updateSelection(); + }); + } + table.addEventListener('change', (e) => { + const t = e.target; + if (t && t.matches('[data-report-select]')) updateSelection(); + }); + if (bulkBtn) { + bulkBtn.addEventListener('click', (e) => { + e.preventDefault(); + handleBulkResolve(); + }); + } + updateSelection(); + } + + function wireAsyncForms() { + table.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.getAttribute('data-report-id') || form.querySelector('[name=\"report_id\"]')?.value; + setLoading(btn, true); + submitForm(form) + .then(() => { + if (id) markResolved(id); + showToast('Resolved report', true); + }) + .catch((err) => showToast(err && err.message ? err.message : String(err), false)) + .finally(() => setLoading(btn, false)); + }); + }); + } + + wireSelection(); + wireAsyncForms(); +})(); +" + + h.script([a.attribute("defer", "defer")], js) } fn error_view(err: common.ApiError) { diff --git a/fluxer_admin/src/fluxer_admin/pages/user_detail_page.gleam b/fluxer_admin/src/fluxer_admin/pages/user_detail_page.gleam index 22b21d92..7e214580 100644 --- a/fluxer_admin/src/fluxer_admin/pages/user_detail_page.gleam +++ b/fluxer_admin/src/fluxer_admin/pages/user_detail_page.gleam @@ -121,85 +121,99 @@ pub fn view( h.div( [a.class("bg-white border border-neutral-200 rounded-lg p-6 mb-6")], [ - h.div([a.class("flex items-start gap-6")], [ - h.div([a.class("flex-shrink-0")], [ - h.img([ - a.src(avatar.get_user_avatar_url( - ctx.media_endpoint, - ctx.cdn_endpoint, - user_data.id, - user_data.avatar, - True, - ctx.asset_version, - )), - a.alt(user_data.username), - a.class("w-24 h-24 rounded-full"), - ]), - ]), - h.div([a.class("flex-1")], [ - h.div([a.class("flex items-center gap-3 mb-3")], [ - ui.heading_section( - user_data.username - <> "#" - <> user.format_discriminator(user_data.discriminator), - ), - case user_data.bot { - True -> - h.span( - [ - a.class( - "px-2 py-1 bg-blue-100 text-blue-700 text-sm font-medium rounded uppercase", - ), - ], - [element.text("Bot")], - ) - False -> element.none() - }, - ]), - case list.is_empty(badges) { - False -> - h.div( - [a.class("flex items-center gap-2 mb-3")], - list.map(badges, fn(b) { - h.img([ - a.src(b.icon), - a.alt(b.name), - a.title(b.name), - a.class("w-6 h-6"), - ]) - }), - ) - True -> element.none() - }, - h.div([a.class("flex flex-wrap items-start gap-4")], [ - h.div([a.class("flex items-start gap-2")], [ - h.div([a.class("text-sm font-medium text-neutral-600")], [ - element.text("User ID:"), - ]), - h.div([a.class("text-sm text-neutral-900")], [ - element.text(user_data.id), + h.div( + [ + a.class( + "flex flex-col gap-4 sm:flex-row sm:items-start sm:gap-6", + ), + ], + [ + h.div( + [ + a.class( + "flex-shrink-0 flex items-center sm:block justify-center", + ), + ], + [ + h.img([ + a.src(avatar.get_user_avatar_url( + ctx.media_endpoint, + ctx.cdn_endpoint, + user_data.id, + user_data.avatar, + True, + ctx.asset_version, + )), + a.alt(user_data.username), + a.class("w-24 h-24 rounded-full"), ]), + ], + ), + h.div([a.class("flex-1")], [ + h.div([a.class("flex flex-wrap items-center gap-3 mb-3")], [ + ui.heading_section( + user_data.username + <> "#" + <> user.format_discriminator(user_data.discriminator), + ), + case user_data.bot { + True -> + h.span( + [ + a.class( + "px-2 py-1 bg-blue-100 text-blue-700 text-sm font-medium rounded uppercase", + ), + ], + [element.text("Bot")], + ) + False -> element.none() + }, ]), - case user.extract_timestamp(user_data.id) { - Ok(created_at) -> - h.div([a.class("flex items-start gap-2")], [ - h.div( - [ - a.class("text-sm font-medium text-neutral-600"), - ], - [ - element.text("Created:"), - ], - ), - h.div([a.class("text-sm text-neutral-900")], [ - element.text(created_at), - ]), - ]) - Error(_) -> element.none() + case list.is_empty(badges) { + False -> + h.div( + [a.class("flex items-center gap-2 mb-3 flex-wrap")], + list.map(badges, fn(b) { + h.img([ + a.src(b.icon), + a.alt(b.name), + a.title(b.name), + a.class("w-6 h-6"), + ]) + }), + ) + True -> element.none() }, + h.div([a.class("flex flex-wrap items-start gap-4")], [ + h.div([a.class("flex items-start gap-2 min-w-0")], [ + h.div([a.class("text-sm font-medium text-neutral-600")], [ + element.text("User ID:"), + ]), + h.div([a.class("text-sm text-neutral-900 break-all")], [ + element.text(user_data.id), + ]), + ]), + case user.extract_timestamp(user_data.id) { + Ok(created_at) -> + h.div([a.class("flex items-start gap-2")], [ + h.div( + [ + a.class("text-sm font-medium text-neutral-600"), + ], + [ + element.text("Created:"), + ], + ), + h.div([a.class("text-sm text-neutral-900")], [ + element.text(created_at), + ]), + ]) + Error(_) -> element.none() + }, + ]), ]), - ]), - ]), + ], + ), ], ), render_tabs( diff --git a/fluxer_admin/src/fluxer_admin/pages/users_page.gleam b/fluxer_admin/src/fluxer_admin/pages/users_page.gleam index 8fd04db0..1e9a550f 100644 --- a/fluxer_admin/src/fluxer_admin/pages/users_page.gleam +++ b/fluxer_admin/src/fluxer_admin/pages/users_page.gleam @@ -114,7 +114,7 @@ pub fn view( fn render_search_form(ctx: Context, query: option.Option(String)) { ui.card(ui.PaddingSmall, [ h.form([a.method("get"), a.class("flex flex-col gap-4")], [ - h.div([a.class("flex gap-2")], [ + h.div([a.class("flex flex-col sm:flex-row gap-2")], [ h.input([ a.type_("text"), a.name("q"), @@ -125,12 +125,12 @@ fn render_search_form(ctx: Context, query: option.Option(String)) { ), a.attribute("autocomplete", "off"), ]), - ui.button_primary("Search", "submit", []), + ui.button_primary("Search", "submit", [a.class("w-full sm:w-auto")]), h.a( [ href(ctx, "/users"), a.class( - "px-4 py-2 bg-white text-neutral-700 border border-neutral-300 rounded-lg text-sm font-medium hover:bg-neutral-50 transition-colors", + "px-4 py-2 bg-white text-neutral-700 border border-neutral-300 rounded-lg text-sm font-medium hover:bg-neutral-50 transition-colors w-full sm:w-auto text-center", ), ], [element.text("Clear")], diff --git a/fluxer_admin/src/fluxer_admin/router.gleam b/fluxer_admin/src/fluxer_admin/router.gleam index 173a4f1a..27533389 100644 --- a/fluxer_admin/src/fluxer_admin/router.gleam +++ b/fluxer_admin/src/fluxer_admin/router.gleam @@ -161,6 +161,19 @@ fn get_bool_query(req: Request, key: String) -> Bool { } } +fn clamp_limit(limit: Int) -> Int { + let min = 10 + let max = 200 + case limit < min { + True -> min + False -> + case limit > max { + True -> max + False -> limit + } + } +} + fn api_error_message(err: common.ApiError) -> String { case err { common.Unauthorized -> "Unauthorized" @@ -897,27 +910,71 @@ fn handle_authenticated_request(req: Request, ctx: Context) -> Response { Get -> { use user_session, current_admin <- with_session_and_admin(req, ctx) let flash_data = flash.from_request(req) + let admin_acls = case current_admin { + option.Some(admin) -> admin.acls + _ -> [] + } - pending_verifications_page.view( - ctx, - user_session, - current_admin, - flash_data, - ) + case + acl.has_permission( + admin_acls, + constants.acl_pending_verification_view, + ) + { + True -> + pending_verifications_page.view( + ctx, + user_session, + current_admin, + flash_data, + ) + False -> + wisp.response(403) + |> wisp.string_body( + "Forbidden: requires pending_verification:view permission", + ) + } } Post -> { use user_session <- with_session(req, ctx) let query = wisp.get_query(req) let action = list.key_find(query, "action") |> option.from_result let background = get_bool_query(req, "background") + let admin_result = users.get_current_admin(ctx, user_session) + let admin_acls = case admin_result { + Ok(option.Some(admin)) -> admin.acls + _ -> [] + } - pending_verifications_page.handle_action( - req, - ctx, - user_session, - action, - background, - ) + case + acl.has_permission( + admin_acls, + constants.acl_pending_verification_review, + ) + { + True -> + pending_verifications_page.handle_action( + req, + ctx, + user_session, + action, + background, + ) + False -> + case background { + True -> + wisp.json_response( + "{\"error\": \"Forbidden: requires pending_verification:review permission\"}", + 403, + ) + False -> + flash.redirect_with_error( + ctx, + "/pending-verifications", + "Forbidden: requires pending_verification:review permission", + ) + } + } } _ -> wisp.method_not_allowed([Get, Post]) } @@ -932,8 +989,32 @@ fn handle_authenticated_request(req: Request, ctx: Context) -> Response { |> option.unwrap("1") let page = int.parse(page_str) |> option.from_result |> option.unwrap(1) + let admin_result = users.get_current_admin(ctx, user_session) + let admin_acls = case admin_result { + Ok(option.Some(admin)) -> admin.acls + _ -> [] + } + let can_review = + acl.has_permission( + admin_acls, + constants.acl_pending_verification_review, + ) - pending_verifications_page.view_fragment(ctx, user_session, page) + case + acl.has_permission( + admin_acls, + constants.acl_pending_verification_view, + ) + { + True -> + pending_verifications_page.view_fragment( + ctx, + user_session, + page, + can_review, + ) + False -> wisp.response(403) |> wisp.string_body("Forbidden") + } } _ -> wisp.method_not_allowed([Get]) } @@ -985,34 +1066,39 @@ fn handle_authenticated_request(req: Request, ctx: Context) -> Response { |> option.unwrap("0") let page = int.parse(page_str) |> option.from_result |> option.unwrap(0) - let fragment = get_bool_query(req, "fragment") - let table_view = get_bool_query(req, "view") + let sort = + list.key_find(query, "sort") + |> option.from_result + |> option.map(fn(s) { + case string.trim(s) { + "" -> option.None + v -> option.Some(v) + } + }) + |> option.unwrap(option.None) + let limit_str = + list.key_find(query, "limit") + |> option.from_result + |> option.unwrap("50") + let limit = + int.parse(limit_str) + |> option.from_result + |> option.unwrap(50) + |> clamp_limit - case fragment { - True -> - reports_page.view_fragment( - ctx, - user_session, - search_query, - status_filter, - type_filter, - category_filter, - page, - ) - False -> - reports_page.view_with_mode( - ctx, - user_session, - current_admin, - flash_data, - search_query, - status_filter, - type_filter, - category_filter, - page, - table_view, - ) - } + reports_page.view_with_mode( + ctx, + user_session, + current_admin, + flash_data, + search_query, + status_filter, + type_filter, + category_filter, + page, + limit, + sort, + ) } _ -> wisp.method_not_allowed([Get]) } diff --git a/fluxer_api/src/admin/controllers/VerificationAdminController.ts b/fluxer_api/src/admin/controllers/VerificationAdminController.ts index 20378ff1..d221b98f 100644 --- a/fluxer_api/src/admin/controllers/VerificationAdminController.ts +++ b/fluxer_api/src/admin/controllers/VerificationAdminController.ts @@ -30,7 +30,7 @@ export const VerificationAdminController = (app: HonoApp) => { app.post( '/admin/pending-verifications/list', RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), - requireAdminACL(AdminACLs.USER_LOOKUP), + requireAdminACL(AdminACLs.PENDING_VERIFICATION_VIEW), Validator('json', z.object({limit: z.number().default(100)})), async (ctx) => { const adminService = ctx.get('adminService'); @@ -42,7 +42,7 @@ export const VerificationAdminController = (app: HonoApp) => { app.post( '/admin/pending-verifications/approve', RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), - requireAdminACL(AdminACLs.USER_UPDATE_FLAGS), + requireAdminACL(AdminACLs.PENDING_VERIFICATION_REVIEW), Validator('json', z.object({user_id: Int64Type})), async (ctx) => { const adminService = ctx.get('adminService'); @@ -56,7 +56,7 @@ export const VerificationAdminController = (app: HonoApp) => { app.post( '/admin/pending-verifications/reject', RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), - requireAdminACL(AdminACLs.USER_UPDATE_FLAGS), + requireAdminACL(AdminACLs.PENDING_VERIFICATION_REVIEW), Validator('json', z.object({user_id: Int64Type})), async (ctx) => { const adminService = ctx.get('adminService'); @@ -70,7 +70,7 @@ export const VerificationAdminController = (app: HonoApp) => { app.post( '/admin/pending-verifications/bulk-approve', RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), - requireAdminACL(AdminACLs.USER_UPDATE_FLAGS), + requireAdminACL(AdminACLs.PENDING_VERIFICATION_REVIEW), Validator('json', z.object({user_ids: z.array(Int64Type).min(1)})), async (ctx) => { const adminService = ctx.get('adminService'); @@ -85,7 +85,7 @@ export const VerificationAdminController = (app: HonoApp) => { app.post( '/admin/pending-verifications/bulk-reject', RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), - requireAdminACL(AdminACLs.USER_UPDATE_FLAGS), + requireAdminACL(AdminACLs.PENDING_VERIFICATION_REVIEW), Validator('json', z.object({user_ids: z.array(Int64Type).min(1)})), async (ctx) => { const adminService = ctx.get('adminService'); diff --git a/fluxer_api/src/auth/AuthService.ts b/fluxer_api/src/auth/AuthService.ts index 2be095cb..9a6cff70 100644 --- a/fluxer_api/src/auth/AuthService.ts +++ b/fluxer_api/src/auth/AuthService.ts @@ -165,7 +165,13 @@ export class AuthService implements IAuthService { botMfaMirrorService?: BotMfaMirrorService, authMfaService?: AuthMfaService, ) { - this.utilityService = new AuthUtilityService(repository, rateLimitService, gatewayService); + this.utilityService = new AuthUtilityService( + repository, + rateLimitService, + gatewayService, + inviteService, + pendingJoinInviteStore, + ); this.sessionService = new AuthSessionService( repository, diff --git a/fluxer_api/src/auth/services/AuthUtilityService.ts b/fluxer_api/src/auth/services/AuthUtilityService.ts index 71fd4fd4..22684d1f 100644 --- a/fluxer_api/src/auth/services/AuthUtilityService.ts +++ b/fluxer_api/src/auth/services/AuthUtilityService.ts @@ -19,14 +19,17 @@ import crypto from 'node:crypto'; import {promisify} from 'node:util'; -import type {UserID} from '~/BrandedTypes'; +import {createInviteCode, type UserID} from '~/BrandedTypes'; import {APIErrorCodes, UserFlags} from '~/Constants'; import {AccessDeniedError, FluxerAPIError, InputValidationError, UnauthorizedError} from '~/Errors'; import type {IGatewayService} from '~/infrastructure/IGatewayService'; import type {IRateLimitService} from '~/infrastructure/IRateLimitService'; +import type {PendingJoinInviteStore} from '~/infrastructure/PendingJoinInviteStore'; +import type {InviteService} from '~/invite/InviteService'; import {Logger} from '~/Logger'; import {getUserSearchService} from '~/Meilisearch'; import type {User} from '~/Models'; +import {createRequestCache} from '~/middleware/RequestCacheMiddleware'; import type {IUserRepository} from '~/user/IUserRepository'; import {mapUserToPrivateResponse} from '~/user/UserModel'; import * as AgeUtils from '~/utils/AgeUtils'; @@ -61,6 +64,8 @@ export class AuthUtilityService { private repository: IUserRepository, private rateLimitService: IRateLimitService, private gatewayService: IGatewayService, + private inviteService: InviteService, + private pendingJoinInviteStore: PendingJoinInviteStore, ) {} async generateSecureToken(length = 64): Promise { @@ -210,5 +215,29 @@ export class AuthUtilityService { event: 'USER_UPDATE', data: mapUserToPrivateResponse(updatedUser!), }); + + await this.autoJoinPendingInvite(userId); + } + + private async autoJoinPendingInvite(userId: UserID): Promise { + 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); + } } } diff --git a/fluxer_api/src/channel/services/BaseChannelAuthService.ts b/fluxer_api/src/channel/services/BaseChannelAuthService.ts index bcfc409a..c9a34868 100644 --- a/fluxer_api/src/channel/services/BaseChannelAuthService.ts +++ b/fluxer_api/src/channel/services/BaseChannelAuthService.ts @@ -174,6 +174,11 @@ export abstract class BaseChannelAuthService { async validateDMSendPermissions({channelId, userId}: {channelId: ChannelID; userId: UserID}): Promise { const channel = await this.channelRepository.channelData.findUnique(channelId); if (!channel) throw new UnknownChannelError(); + + if (channel.type === ChannelTypes.GROUP_DM || channel.type === ChannelTypes.DM_PERSONAL_NOTES) { + return; + } + const recipients = await this.userRepository.listUsers(Array.from(channel.recipientIds)); await this.dmPermissionValidator.validate({recipients, userId}); } diff --git a/fluxer_api/src/channel/services/CallService.ts b/fluxer_api/src/channel/services/CallService.ts index 3c3ffa72..40a31296 100644 --- a/fluxer_api/src/channel/services/CallService.ts +++ b/fluxer_api/src/channel/services/CallService.ts @@ -72,6 +72,12 @@ export class CallService { throw new InvalidChannelTypeForCallError(); } + const caller = await this.userRepository.findUnique(userId); + const isUnclaimedCaller = caller != null && !caller.passwordHash && !caller.isBot; + if (isUnclaimedCaller && channel.type === ChannelTypes.DM) { + return {ringable: false}; + } + const call = await this.gatewayService.getCall(channelId); const alreadyInCall = call ? call.voice_states.some((vs) => vs.user_id === userId.toString()) : false; if (alreadyInCall) { diff --git a/fluxer_api/src/constants/API.ts b/fluxer_api/src/constants/API.ts index 6cb4f6ec..a3bc0a18 100644 --- a/fluxer_api/src/constants/API.ts +++ b/fluxer_api/src/constants/API.ts @@ -217,6 +217,8 @@ export const AdminACLs = { USER_DISABLE_SUSPICIOUS: 'user:disable:suspicious', USER_DELETE: 'user:delete', USER_CANCEL_BULK_MESSAGE_DELETION: 'user:cancel:bulk_message_deletion', + PENDING_VERIFICATION_VIEW: 'pending_verification:view', + PENDING_VERIFICATION_REVIEW: 'pending_verification:review', BETA_CODES_GENERATE: 'beta_codes:generate', GIFT_CODES_GENERATE: 'gift_codes:generate', diff --git a/fluxer_api/src/oauth/ApplicationService.ts b/fluxer_api/src/oauth/ApplicationService.ts index 93298e61..e74f52cb 100644 --- a/fluxer_api/src/oauth/ApplicationService.ts +++ b/fluxer_api/src/oauth/ApplicationService.ts @@ -24,7 +24,12 @@ import {applicationIdToUserId} from '~/BrandedTypes'; import {UserFlags, UserPremiumTypes} from '~/Constants'; import type {UserRow} from '~/database/CassandraTypes'; import type {ApplicationRow} from '~/database/types/OAuth2Types'; -import {BotUserNotFoundError, InputValidationError, UnknownApplicationError} from '~/Errors'; +import { + BotUserNotFoundError, + InputValidationError, + UnclaimedAccountRestrictedError, + UnknownApplicationError, +} from '~/Errors'; import type {DiscriminatorService} from '~/infrastructure/DiscriminatorService'; import type {EntityAssetService, PreparedAssetUpload} from '~/infrastructure/EntityAssetService'; import type {IGatewayService} from '~/infrastructure/IGatewayService'; @@ -130,6 +135,10 @@ export class ApplicationService { const owner = await this.deps.userRepository.findUniqueAssert(args.ownerUserId); const botIsPublic = args.botPublic ?? true; + if (!owner.passwordHash && !owner.isBot) { + throw new UnclaimedAccountRestrictedError('create applications'); + } + const applicationId: ApplicationID = this.deps.snowflakeService.generate() as ApplicationID; const botUserId = applicationIdToUserId(applicationId); diff --git a/fluxer_api/src/stripe/services/StripeCheckoutService.ts b/fluxer_api/src/stripe/services/StripeCheckoutService.ts index 1ae4c143..35194eb3 100644 --- a/fluxer_api/src/stripe/services/StripeCheckoutService.ts +++ b/fluxer_api/src/stripe/services/StripeCheckoutService.ts @@ -21,7 +21,13 @@ import type Stripe from 'stripe'; import type {UserID} from '~/BrandedTypes'; import {Config} from '~/Config'; import {UserFlags, UserPremiumTypes} from '~/Constants'; -import {NoVisionarySlotsAvailableError, PremiumPurchaseBlockedError, StripeError, UnknownUserError} from '~/Errors'; +import { + NoVisionarySlotsAvailableError, + PremiumPurchaseBlockedError, + StripeError, + UnclaimedAccountRestrictedError, + UnknownUserError, +} from '~/Errors'; import {Logger} from '~/Logger'; import type {User} from '~/Models'; import type {IUserRepository} from '~/user/IUserRepository'; @@ -212,6 +218,10 @@ export class StripeCheckoutService { } validateUserCanPurchase(user: User): void { + if (!user.passwordHash && !user.isBot) { + throw new UnclaimedAccountRestrictedError('make purchases'); + } + if (user.flags & UserFlags.PREMIUM_PURCHASE_DISABLED) { throw new PremiumPurchaseBlockedError(); } diff --git a/fluxer_api/src/user/controllers/UserAccountController.ts b/fluxer_api/src/user/controllers/UserAccountController.ts index 62acf6ac..2d9743fe 100644 --- a/fluxer_api/src/user/controllers/UserAccountController.ts +++ b/fluxer_api/src/user/controllers/UserAccountController.ts @@ -205,6 +205,8 @@ export const UserAccountController = (app: HonoApp) => { const emailTokenProvided = emailToken !== undefined; const isUnclaimed = !user.passwordHash; if (isUnclaimed) { + const {username: _ignoredUsername, discriminator: _ignoredDiscriminator, ...rest} = userUpdateData; + userUpdateData = rest; const allowed = new Set(['new_password']); const disallowedField = Object.keys(userUpdateData).find((key) => !allowed.has(key)); if (disallowedField) { diff --git a/fluxer_api/src/user/services/UserContentService.ts b/fluxer_api/src/user/services/UserContentService.ts index 1cc862e7..4f6fbc0d 100644 --- a/fluxer_api/src/user/services/UserContentService.ts +++ b/fluxer_api/src/user/services/UserContentService.ts @@ -34,6 +34,7 @@ import { HarvestOnCooldownError, MaxBookmarksError, MissingPermissionsError, + UnclaimedAccountRestrictedError, UnknownChannelError, UnknownHarvestError, UnknownMessageError, @@ -120,6 +121,10 @@ export class UserContentService { throw new UnknownUserError(); } + if (!user.passwordHash && !user.isBot) { + throw new UnclaimedAccountRestrictedError('create beta codes'); + } + const existingBetaCodes = await this.userContentRepository.listBetaCodes(userId); const unclaimedCount = existingBetaCodes.filter((code) => !code.redeemerId).length; diff --git a/fluxer_api/src/voice/VoiceService.ts b/fluxer_api/src/voice/VoiceService.ts index c7d9a49f..7131255d 100644 --- a/fluxer_api/src/voice/VoiceService.ts +++ b/fluxer_api/src/voice/VoiceService.ts @@ -18,9 +18,11 @@ */ import type {ChannelID, GuildID, UserID} from '~/BrandedTypes'; +import {ChannelTypes} from '~/Constants'; import type {IChannelRepository} from '~/channel/IChannelRepository'; import { FeatureTemporarilyDisabledError, + UnclaimedAccountRestrictedError, UnknownChannelError, UnknownGuildMemberError, UnknownUserError, @@ -114,6 +116,21 @@ export class VoiceService { throw new UnknownChannelError(); } + const isUnclaimed = !user.passwordHash && !user.isBot; + if (isUnclaimed) { + if (channel.type === ChannelTypes.DM) { + throw new UnclaimedAccountRestrictedError('join 1:1 voice calls'); + } + + if (channel.type === ChannelTypes.GUILD_VOICE) { + const guild = guildId ? await this.guildRepository.findUnique(guildId) : null; + const isOwner = guild?.ownerId === userId; + if (!isOwner) { + throw new UnclaimedAccountRestrictedError('join voice channels you do not own'); + } + } + } + let mute = false; let deaf = false; diff --git a/fluxer_app/electron-builder.canary.yaml b/fluxer_app/electron-builder.canary.yaml index 32154e8c..f90cb333 100644 --- a/fluxer_app/electron-builder.canary.yaml +++ b/fluxer_app/electron-builder.canary.yaml @@ -96,8 +96,8 @@ linux: icon: electron-build-resources/icons-canary category: Network maintainer: Fluxer Contributors - synopsis: Chat that puts you first (Canary) - description: Canary build of Fluxer. Chat that puts you first. Built for friends, groups, and communities. + synopsis: Fluxer Canary + description: Fluxer Canary executableName: fluxercanary target: - dir diff --git a/fluxer_app/electron-builder.yaml b/fluxer_app/electron-builder.yaml index b044f8b9..44bbb4b9 100644 --- a/fluxer_app/electron-builder.yaml +++ b/fluxer_app/electron-builder.yaml @@ -96,8 +96,8 @@ linux: icon: electron-build-resources/icons-stable category: Network maintainer: Fluxer Contributors - synopsis: Chat that puts you first - description: Chat that puts you first. Built for friends, groups, and communities. Text, voice, and video. Open source and community-funded. + synopsis: Fluxer + description: Fluxer executableName: fluxer target: - dir diff --git a/fluxer_app/index.html b/fluxer_app/index.html index fda11c9a..1e969c59 100644 --- a/fluxer_app/index.html +++ b/fluxer_app/index.html @@ -4,7 +4,7 @@ Fluxer - + diff --git a/fluxer_app/knip.json b/fluxer_app/knip.json index 45b7d9e2..9314f55e 100644 --- a/fluxer_app/knip.json +++ b/fluxer_app/knip.json @@ -16,8 +16,7 @@ "undici", "update-electron-app", "@types/ws", - "electron-builder-squirrel-windows", - "esbuild" + "electron-builder-squirrel-windows" ], "ignoreBinaries": ["go"], "ignoreExportsUsedInFile": true, diff --git a/fluxer_app/package.json b/fluxer_app/package.json index 0e0a9903..695163da 100644 --- a/fluxer_app/package.json +++ b/fluxer_app/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "type": "module", "private": true, - "description": "Fluxer desktop client. Chat that puts you first.", + "description": "Fluxer is an open-source, independent instant messaging and VoIP platform. Built for friends, groups, and communities.", "homepage": "https://fluxer.app", "author": "Fluxer Contributors ", "sideEffects": [ diff --git a/fluxer_app/scripts/build/rspack/static-files.mjs b/fluxer_app/scripts/build/rspack/static-files.mjs index 75504932..ed9dd782 100644 --- a/fluxer_app/scripts/build/rspack/static-files.mjs +++ b/fluxer_app/scripts/build/rspack/static-files.mjs @@ -31,7 +31,7 @@ function generateManifest(cdnEndpointRaw) { name: 'Fluxer', short_name: 'Fluxer', description: - 'Chat that puts you first. Built for friends, groups, and communities. Text, voice, and video. Open source and community-funded.', + 'Fluxer is an open-source, independent instant messaging and VoIP platform. Built for friends, groups, and communities.', start_url: '/', display: 'standalone', orientation: 'portrait-primary', diff --git a/fluxer_app/src/Constants.tsx b/fluxer_app/src/Constants.tsx index a23a59ce..3053083f 100644 --- a/fluxer_app/src/Constants.tsx +++ b/fluxer_app/src/Constants.tsx @@ -508,6 +508,7 @@ export const GatewayErrorCodes = { VOICE_TOKEN_FAILED: 'VOICE_TOKEN_FAILED', VOICE_GUILD_ID_MISSING: 'VOICE_GUILD_ID_MISSING', VOICE_INVALID_GUILD_ID: 'VOICE_INVALID_GUILD_ID', + VOICE_UNCLAIMED_ACCOUNT: 'VOICE_UNCLAIMED_ACCOUNT', DM_NOT_RECIPIENT: 'DM_NOT_RECIPIENT', DM_INVALID_CHANNEL_TYPE: 'DM_INVALID_CHANNEL_TYPE', UNKNOWN_ERROR: 'UNKNOWN_ERROR', diff --git a/fluxer_app/src/actions/UserProfileActionCreators.tsx b/fluxer_app/src/actions/UserProfileActionCreators.tsx index 89137070..5b9b2400 100644 --- a/fluxer_app/src/actions/UserProfileActionCreators.tsx +++ b/fluxer_app/src/actions/UserProfileActionCreators.tsx @@ -73,6 +73,7 @@ export const fetch = async (userId: string, guildId?: string, force = false): Pr query: { ...(guildId ? {guild_id: guildId} : {}), with_mutual_friends: true, + with_mutual_guilds: true, }, }); const profile = response.body; diff --git a/fluxer_app/src/bootstrap/setupI18n.ts b/fluxer_app/src/bootstrap/setupI18n.ts deleted file mode 100644 index 1c174b4b..00000000 --- a/fluxer_app/src/bootstrap/setupI18n.ts +++ /dev/null @@ -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 . - */ - -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); -} diff --git a/fluxer_app/src/components/auth/DateOfBirthField.tsx b/fluxer_app/src/components/auth/DateOfBirthField.tsx index 63fc7308..18c277f7 100644 --- a/fluxer_app/src/components/auth/DateOfBirthField.tsx +++ b/fluxer_app/src/components/auth/DateOfBirthField.tsx @@ -84,9 +84,6 @@ function NativeDatePicker({ error, }: NativeDatePickerProps) { const {t} = useLingui(); - const _monthPlaceholder = t`Month`; - const _dayPlaceholder = t`Day`; - const _yearPlaceholder = t`Year`; const dateOfBirthPlaceholder = t`Date of birth`; const currentYear = new Date().getFullYear(); diff --git a/fluxer_app/src/components/bottomsheets/ChannelDetailsBottomSheet.tsx b/fluxer_app/src/components/bottomsheets/ChannelDetailsBottomSheet.tsx index 2896e7a0..224ceab8 100644 --- a/fluxer_app/src/components/bottomsheets/ChannelDetailsBottomSheet.tsx +++ b/fluxer_app/src/components/bottomsheets/ChannelDetailsBottomSheet.tsx @@ -131,6 +131,7 @@ import * as ChannelUtils from '~/utils/ChannelUtils'; import {getMutedText, getNotificationSettingsLabel} from '~/utils/ContextMenuUtils'; import {MAX_GROUP_DM_RECIPIENTS} from '~/utils/groupDmUtils'; import * as InviteUtils from '~/utils/InviteUtils'; +import * as MemberListUtils from '~/utils/MemberListUtils'; import {buildChannelLink} from '~/utils/messageLinkUtils'; import * as NicknameUtils from '~/utils/NicknameUtils'; import * as RouterUtils from '~/utils/RouterUtils'; @@ -278,7 +279,18 @@ interface LazyMemberListGroupProps { const LazyMemberListGroup = observer( ({guild, group, channelId, members, onMemberLongPress}: LazyMemberListGroupProps) => { const {t} = useLingui(); - const groupName = group.id === 'online' ? t`Online` : group.id === 'offline' ? t`Offline` : group.id; + const groupName = (() => { + switch (group.id) { + case 'online': + return t`Online`; + case 'offline': + return t`Offline`; + default: { + const role = guild.getRole(group.id); + return role?.name ?? group.id; + } + } + })(); return (
@@ -321,6 +333,7 @@ const LazyGuildMemberList = observer( guildId: guild.id, channelId: channel.id, enabled, + allowInitialUnfocusedLoad: true, }); const memberListState = MemberSidebarStore.getList(guild.id, channel.id); @@ -364,21 +377,22 @@ const LazyGuildMemberList = observer( const groupedItems: Map> = new Map(); const groups = memberListState.groups; + const seenMemberIds = new Set(); for (const group of groups) { groupedItems.set(group.id, []); } - for (const [, item] of memberListState.items) { - if (item.type === 'member') { + let currentGroup: string | null = null; + const sortedItems = Array.from(memberListState.items.entries()).sort(([a], [b]) => a - b); + for (const [, item] of sortedItems) { + if (item.type === 'group') { + currentGroup = (item.data as {id: string}).id; + } else if (item.type === 'member' && currentGroup) { const member = item.data as GuildMemberRecord; - for (let i = groups.length - 1; i >= 0; i--) { - const group = groups[i]; - const members = groupedItems.get(group.id); - if (members) { - members.push(member); - break; - } + if (!seenMemberIds.has(member.user.id)) { + seenMemberIds.add(member.user.id); + groupedItems.get(currentGroup)?.push(member); } } } @@ -734,6 +748,24 @@ export const ChannelDetailsBottomSheet: React.FC }, []); const isMemberTabVisible = isOpen && activeTab === 'members'; + const dmMemberGroups = (() => { + if (!(isDM || isGroupDM || isPersonalNotes)) return []; + + const currentUserId = AuthenticationStore.currentUserId; + let memberIds: Array = []; + + if (isPersonalNotes) { + memberIds = currentUser ? [currentUser.id] : []; + } else { + memberIds = [...channel.recipientIds]; + if (currentUserId && !memberIds.includes(currentUserId)) { + memberIds.push(currentUserId); + } + } + + const users = memberIds.map((id) => UserStore.getUser(id)).filter((u): u is UserRecord => u !== null); + return MemberListUtils.getGroupDMMemberGroups(users); + })(); return ( <> @@ -910,68 +942,60 @@ export const ChannelDetailsBottomSheet: React.FC )}
- Members —{' '} - {isPersonalNotes ? 1 : isGroupDM ? channel.recipientIds.length + 1 : 2} + Members — {dmMemberGroups.reduce((total, group) => total + group.count, 0)}
- {(() => { - let memberIds: Array = []; - if (isPersonalNotes) { - memberIds = currentUser ? [currentUser.id] : []; - } else if (isGroupDM) { - memberIds = [...channel.recipientIds]; - if (currentUser && !memberIds.includes(currentUser.id)) { - memberIds.push(currentUser.id); - } - } else if (isDM) { - memberIds = [...channel.recipientIds]; - if (currentUser && !memberIds.includes(currentUser.id)) { - memberIds.push(currentUser.id); - } - } + {dmMemberGroups.map((group) => ( +
+
+ {group.displayName} — {group.count} +
+
+ {group.users.map((user, index) => { + const isCurrentUser = user.id === currentUser?.id; + const isOwner = isGroupDM && channel.ownerId === user.id; - return memberIds.map((userId, index, arr) => { - const user = UserStore.getUser(userId); - if (!user) return null; + const handleUserClick = () => { + UserProfileActionCreators.openUserProfile(user.id); + }; - const isCurrentUser = user.id === currentUser?.id; - const isOwner = isGroupDM && channel.ownerId === user.id; - - const handleUserClick = () => { - UserProfileActionCreators.openUserProfile(user.id); - }; - - return ( - - - {index < arr.length - 1 &&
} - - ); - }); - })()} + + {index < group.users.length - 1 &&
} + + ); + })} +
+
+ ))}
)} diff --git a/fluxer_app/src/components/bottomsheets/DMBottomSheet.tsx b/fluxer_app/src/components/bottomsheets/DMBottomSheet.tsx index 141982ae..ee02525c 100644 --- a/fluxer_app/src/components/bottomsheets/DMBottomSheet.tsx +++ b/fluxer_app/src/components/bottomsheets/DMBottomSheet.tsx @@ -78,6 +78,7 @@ import RuntimeConfigStore from '~/stores/RuntimeConfigStore'; import SelectedChannelStore from '~/stores/SelectedChannelStore'; import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore'; import UserProfileMobileStore from '~/stores/UserProfileMobileStore'; +import UserStore from '~/stores/UserStore'; import * as CallUtils from '~/utils/CallUtils'; import {getMutedText} from '~/utils/ContextMenuUtils'; import * as InviteUtils from '~/utils/InviteUtils'; @@ -113,6 +114,7 @@ export const DMBottomSheet: React.FC = observer(({isOpen, on const isRecipientBot = recipient?.bot; const relationship = recipient ? RelationshipStore.getRelationship(recipient.id) : null; const relationshipType = relationship?.type; + const currentUserUnclaimed = !(UserStore.currentUser?.isClaimed() ?? true); const handleMarkAsRead = () => { ReadStateActionCreators.ack(channel.id, true, true); @@ -517,7 +519,8 @@ export const DMBottomSheet: React.FC = observer(({isOpen, on }); } else if ( relationshipType !== RelationshipTypes.OUTGOING_REQUEST && - relationshipType !== RelationshipTypes.BLOCKED + relationshipType !== RelationshipTypes.BLOCKED && + !currentUserUnclaimed ) { relationshipItems.push({ icon: , diff --git a/fluxer_app/src/components/channel/ChannelHeader/CallButtons.tsx b/fluxer_app/src/components/channel/ChannelHeader/CallButtons.tsx index 77ff2217..11e42291 100644 --- a/fluxer_app/src/components/channel/ChannelHeader/CallButtons.tsx +++ b/fluxer_app/src/components/channel/ChannelHeader/CallButtons.tsx @@ -23,8 +23,11 @@ import {PhoneIcon, VideoCameraIcon} from '@phosphor-icons/react'; import {observer} from 'mobx-react-lite'; import React from 'react'; import * as CallActionCreators from '~/actions/CallActionCreators'; +import * as ToastActionCreators from '~/actions/ToastActionCreators'; +import {ChannelTypes} from '~/Constants'; import type {ChannelRecord} from '~/records/ChannelRecord'; import CallStateStore from '~/stores/CallStateStore'; +import UserStore from '~/stores/UserStore'; import MediaEngineStore from '~/stores/voice/MediaEngineFacade'; import * as CallUtils from '~/utils/CallUtils'; import {ChannelHeaderIcon} from './ChannelHeaderIcon'; @@ -38,9 +41,20 @@ const VoiceCallButton = observer(({channel}: {channel: ChannelRecord}) => { const hasActiveCall = CallStateStore.hasActiveCall(channel.id); const participants = call ? CallStateStore.getParticipants(channel.id) : []; const participantCount = participants.length; + const currentUser = UserStore.getCurrentUser(); + const isUnclaimed = !(currentUser?.isClaimed() ?? false); + const is1to1 = channel.type === ChannelTypes.DM; + const blocked = isUnclaimed && is1to1; const handleClick = React.useCallback( async (event: React.MouseEvent) => { + if (blocked) { + ToastActionCreators.createToast({ + type: 'error', + children: t`Claim your account to start or join 1:1 calls.`, + }); + return; + } if (isInCall) { void CallActionCreators.leaveCall(channel.id); } else if (hasActiveCall) { @@ -50,7 +64,7 @@ const VoiceCallButton = observer(({channel}: {channel: ChannelRecord}) => { await CallUtils.checkAndStartCall(channel.id, silent); } }, - [channel.id, isInCall, hasActiveCall], + [channel.id, isInCall, hasActiveCall, blocked], ); let label: string; @@ -67,7 +81,13 @@ const VoiceCallButton = observer(({channel}: {channel: ChannelRecord}) => { : t`Join Voice Call (${participantCount} participants)`; } } else { - label = isInCall ? t`Leave Voice Call` : hasActiveCall ? t`Join Voice Call` : t`Start Voice Call`; + label = blocked + ? t`Claim your account to call` + : isInCall + ? t`Leave Voice Call` + : hasActiveCall + ? t`Join Voice Call` + : t`Start Voice Call`; } return ( @@ -76,6 +96,7 @@ const VoiceCallButton = observer(({channel}: {channel: ChannelRecord}) => { label={label} isSelected={isInCall} onClick={handleClick} + disabled={blocked} keybindAction="start_pm_call" /> ); @@ -90,9 +111,20 @@ const VideoCallButton = observer(({channel}: {channel: ChannelRecord}) => { const hasActiveCall = CallStateStore.hasActiveCall(channel.id); const participants = call ? CallStateStore.getParticipants(channel.id) : []; const participantCount = participants.length; + const currentUser = UserStore.getCurrentUser(); + const isUnclaimed = !(currentUser?.isClaimed() ?? false); + const is1to1 = channel.type === ChannelTypes.DM; + const blocked = isUnclaimed && is1to1; const handleClick = React.useCallback( async (event: React.MouseEvent) => { + if (blocked) { + ToastActionCreators.createToast({ + type: 'error', + children: t`Claim your account to start or join 1:1 calls.`, + }); + return; + } if (isInCall) { void CallActionCreators.leaveCall(channel.id); } else if (hasActiveCall) { @@ -102,7 +134,7 @@ const VideoCallButton = observer(({channel}: {channel: ChannelRecord}) => { await CallUtils.checkAndStartCall(channel.id, silent); } }, - [channel.id, isInCall, hasActiveCall], + [channel.id, isInCall, hasActiveCall, blocked], ); let label: string; @@ -119,10 +151,24 @@ const VideoCallButton = observer(({channel}: {channel: ChannelRecord}) => { : t`Join Video Call (${participantCount} participants)`; } } else { - label = isInCall ? t`Leave Video Call` : hasActiveCall ? t`Join Video Call` : t`Start Video Call`; + label = blocked + ? t`Claim your account to call` + : isInCall + ? t`Leave Video Call` + : hasActiveCall + ? t`Join Video Call` + : t`Start Video Call`; } - return ; + return ( + + ); }); export const CallButtons = { diff --git a/fluxer_app/src/components/channel/ChannelMembers.tsx b/fluxer_app/src/components/channel/ChannelMembers.tsx index 62c9a4d8..38519dc9 100644 --- a/fluxer_app/src/components/channel/ChannelMembers.tsx +++ b/fluxer_app/src/components/channel/ChannelMembers.tsx @@ -33,7 +33,7 @@ import type {UserRecord} from '~/records/UserRecord'; import AuthenticationStore from '~/stores/AuthenticationStore'; import MemberSidebarStore from '~/stores/MemberSidebarStore'; import UserStore from '~/stores/UserStore'; -import type {GroupDMMemberGroup, MemberGroup} from '~/utils/MemberListUtils'; +import type {GroupDMMemberGroup} from '~/utils/MemberListUtils'; import * as MemberListUtils from '~/utils/MemberListUtils'; import * as NicknameUtils from '~/utils/NicknameUtils'; import styles from './ChannelMembers.module.css'; @@ -65,35 +65,6 @@ const SkeletonMemberItem = ({index}: {index: number}) => { ); }; -const _MemberListGroup = observer( - ({guild, group, channelId}: {guild: GuildRecord; group: MemberGroup; channelId: string}) => ( -
-
- {group.displayName} — {group.count} -
-
- {group.members.map((member: GuildMemberRecord) => { - const user = member.user; - const userId = user.id; - return ( - - ); - })} -
-
-
- ), -); - interface GroupDMMemberListGroupProps { group: GroupDMMemberGroup; channelId: string; diff --git a/fluxer_app/src/components/channel/ChannelSearchResults.tsx b/fluxer_app/src/components/channel/ChannelSearchResults.tsx index f2e9aa0a..340e1773 100644 --- a/fluxer_app/src/components/channel/ChannelSearchResults.tsx +++ b/fluxer_app/src/components/channel/ChannelSearchResults.tsx @@ -40,9 +40,7 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators'; import {MessagePreviewContext} from '~/Constants'; import {Message, type MessageBehaviorOverrides} from '~/components/channel/Message'; -import {GroupDMAvatar} from '~/components/common/GroupDMAvatar'; import {MessageContextPrefix} from '~/components/shared/MessageContextPrefix/MessageContextPrefix'; -import {Avatar} from '~/components/uikit/Avatar'; import {Button} from '~/components/uikit/Button/Button'; import {ContextMenuCloseProvider} from '~/components/uikit/ContextMenu/ContextMenu'; import {MenuGroup} from '~/components/uikit/ContextMenu/MenuGroup'; @@ -58,9 +56,7 @@ import ChannelSearchStore, {getChannelSearchContextId} from '~/stores/ChannelSea import ChannelStore from '~/stores/ChannelStore'; import GuildNSFWAgreeStore from '~/stores/GuildNSFWAgreeStore'; import GuildStore from '~/stores/GuildStore'; -import UserStore from '~/stores/UserStore'; import {applyChannelSearchHighlight, clearChannelSearchHighlight} from '~/utils/ChannelSearchHighlight'; -import * as ChannelUtils from '~/utils/ChannelUtils'; import {goToMessage} from '~/utils/MessageNavigator'; import * as RouterUtils from '~/utils/RouterUtils'; import {tokenizeSearchQuery} from '~/utils/SearchQueryTokenizer'; @@ -78,14 +74,6 @@ import type {SearchMachineState} from './SearchResultsUtils'; import {areSegmentsEqual} from './SearchResultsUtils'; import {DEFAULT_SCOPE_VALUE, getScopeOptionsForChannel} from './searchScopeOptions'; -const getChannelDisplayName = (channel: ChannelRecord): string => { - if (channel.isPrivate()) { - return ChannelUtils.getDMDisplayName(channel); - } - - return channel.name?.trim() || ChannelUtils.getName(channel); -}; - const getChannelGuild = (channel: ChannelRecord): GuildRecord | null => { if (!channel.guildId) { return null; @@ -101,37 +89,6 @@ const getChannelPath = (channel: ChannelRecord): string => { return Routes.dmChannel(channel.id); }; -const _renderChannelIcon = (channel: ChannelRecord): React.ReactNode => { - if (channel.isPersonalNotes()) { - return ChannelUtils.getIcon(channel, {className: styles.channelIcon}); - } - - if (channel.isDM()) { - const recipientId = channel.recipientIds[0]; - const recipient = recipientId ? UserStore.getUser(recipientId) : null; - - if (recipient) { - return ( -
- -
- ); - } - - return ChannelUtils.getIcon(channel, {className: styles.channelIcon}); - } - - if (channel.isGroupDM()) { - return ( -
- -
- ); - } - - return ChannelUtils.getIcon(channel, {className: styles.channelIcon}); -}; - interface ChannelSearchResultsProps { channel: ChannelRecord; searchQuery: string; @@ -753,7 +710,6 @@ export const ChannelSearchResults = observer( } const channelGuild = getChannelGuild(messageChannel); - const _channelDisplayName = getChannelDisplayName(messageChannel); const showGuildMeta = shouldShowGuildMetaForScope( channelGuild, (activeScope ?? DEFAULT_SCOPE_VALUE) as MessageSearchScope, diff --git a/fluxer_app/src/components/channel/GiftEmbed.tsx b/fluxer_app/src/components/channel/GiftEmbed.tsx index 59657431..59f955a3 100644 --- a/fluxer_app/src/components/channel/GiftEmbed.tsx +++ b/fluxer_app/src/components/channel/GiftEmbed.tsx @@ -31,6 +31,7 @@ import { } from '~/components/embeds/EmbedCard/EmbedCard'; import cardStyles from '~/components/embeds/EmbedCard/EmbedCard.module.css'; import {useEmbedSkeletonOverride} from '~/components/embeds/EmbedCard/useEmbedSkeletonOverride'; +import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal'; import {Button} from '~/components/uikit/Button/Button'; import i18n from '~/i18n'; import {ComponentDispatch} from '~/lib/ComponentDispatch'; @@ -48,6 +49,7 @@ export const GiftEmbed = observer(function GiftEmbed({code}: GiftEmbedProps) { const giftState = GiftStore.gifts.get(code) ?? null; const gift = giftState?.data; const creator = UserStore.getUser(gift?.created_by?.id ?? ''); + const isUnclaimed = !(UserStore.currentUser?.isClaimed() ?? false); const shouldForceSkeleton = useEmbedSkeletonOverride(); React.useEffect(() => { @@ -76,6 +78,10 @@ export const GiftEmbed = observer(function GiftEmbed({code}: GiftEmbedProps) { const durationText = getGiftDurationText(i18n, gift); const handleRedeem = async () => { + if (isUnclaimed) { + openClaimAccountModal({force: true}); + return; + } try { await GiftActionCreators.redeem(i18n, code); } catch (error) { @@ -87,17 +93,22 @@ export const GiftEmbed = observer(function GiftEmbed({code}: GiftEmbedProps) { {t`From ${creator.username}#${creator.discriminator}`} ) : 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 ? ( + + ) : ( + + ); return ( { diff --git a/fluxer_app/src/components/channel/barriers/BarrierComponents.tsx b/fluxer_app/src/components/channel/barriers/BarrierComponents.tsx index f277f671..a9164ad7 100644 --- a/fluxer_app/src/components/channel/barriers/BarrierComponents.tsx +++ b/fluxer_app/src/components/channel/barriers/BarrierComponents.tsx @@ -23,7 +23,7 @@ import {observer} from 'mobx-react-lite'; import {useEffect, useState} from 'react'; import * as ModalActionCreators from '~/actions/ModalActionCreators'; import {modal} from '~/actions/ModalActionCreators'; -import {ClaimAccountModal} from '~/components/modals/ClaimAccountModal'; +import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal'; import {PhoneAddModal} from '~/components/modals/PhoneAddModal'; import {UserSettingsModal} from '~/components/modals/UserSettingsModal'; import {Button} from '~/components/uikit/Button/Button'; @@ -103,7 +103,7 @@ export const UnclaimedAccountBarrier = observer(({onAction}: BarrierProps) => { small={true} onClick={() => { onAction?.(); - ModalActionCreators.push(modal(() => )); + openClaimAccountModal({force: true}); }} > Claim Account @@ -234,7 +234,7 @@ export const UnclaimedDMBarrier = observer(({onAction}: BarrierProps) => { small={true} onClick={() => { onAction?.(); - ModalActionCreators.push(modal(() => )); + openClaimAccountModal({force: true}); }} > Claim Account diff --git a/fluxer_app/src/components/channel/channel-view/DMChannelView.tsx b/fluxer_app/src/components/channel/channel-view/DMChannelView.tsx index 462b6cd3..30195b5e 100644 --- a/fluxer_app/src/components/channel/channel-view/DMChannelView.tsx +++ b/fluxer_app/src/components/channel/channel-view/DMChannelView.tsx @@ -205,6 +205,7 @@ export const DMChannelView = observer(({channelId}: DMChannelViewProps) => { const title = isDM && displayName ? `@${displayName}` : displayName; useFluxerDocumentTitle(title); const isGroupDM = channel?.type === ChannelTypes.GROUP_DM; + const isPersonalNotes = channel?.type === ChannelTypes.DM_PERSONAL_NOTES; const callHeaderState = useCallHeaderState(channel); const call = callHeaderState.call; const showCompactVoiceView = callHeaderState.controlsVariant === 'inCall'; @@ -411,7 +412,7 @@ export const DMChannelView = observer(({channelId}: DMChannelViewProps) => { textarea={ isDM && isRecipientBlocked && recipient ? ( - ) : isCurrentUserUnclaimed ? ( + ) : isCurrentUserUnclaimed && isDM && !isPersonalNotes && !isGroupDM ? ( ) : ( diff --git a/fluxer_app/src/components/channel/dm/AddFriendForm.tsx b/fluxer_app/src/components/channel/dm/AddFriendForm.tsx index d8169c8f..5c6a9d8c 100644 --- a/fluxer_app/src/components/channel/dm/AddFriendForm.tsx +++ b/fluxer_app/src/components/channel/dm/AddFriendForm.tsx @@ -17,15 +17,19 @@ * along with Fluxer. If not, see . */ -import {useLingui} from '@lingui/react/macro'; +import {Trans, useLingui} from '@lingui/react/macro'; +import {WarningCircleIcon} from '@phosphor-icons/react'; import {clsx} from 'clsx'; import {observer} from 'mobx-react-lite'; import React from 'react'; import * as RelationshipActionCreators from '~/actions/RelationshipActionCreators'; import {APIErrorCodes} from '~/Constants'; import {Input} from '~/components/form/Input'; +import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal'; +import {StatusSlate} from '~/components/modals/shared/StatusSlate'; import {Button} from '~/components/uikit/Button/Button'; import MobileLayoutStore from '~/stores/MobileLayoutStore'; +import UserStore from '~/stores/UserStore'; import {getApiErrorCode} from '~/utils/ApiErrorUtils'; import styles from './AddFriendForm.module.css'; @@ -35,11 +39,30 @@ interface AddFriendFormProps { export const AddFriendForm: React.FC = observer(({onSuccess}) => { const {t} = useLingui(); + const [input, setInput] = React.useState(''); const [isLoading, setIsLoading] = React.useState(false); const [resultStatus, setResultStatus] = React.useState<'success' | 'error' | null>(null); const [errorCode, setErrorCode] = React.useState(null); + const isClaimed = UserStore.currentUser?.isClaimed() ?? true; + if (!isClaimed) { + return ( + Claim your account} + description={Claim your account to send friend requests.} + actions={[ + { + text: Claim Account, + onClick: () => openClaimAccountModal({force: true}), + variant: 'primary', + }, + ]} + /> + ); + } + const parseInput = (input: string): [string, string] => { const parts = input.split('#'); if (parts.length > 1) { diff --git a/fluxer_app/src/components/channel/dm/DMWelcomeSection.tsx b/fluxer_app/src/components/channel/dm/DMWelcomeSection.tsx index 022e9f62..19ea7931 100644 --- a/fluxer_app/src/components/channel/dm/DMWelcomeSection.tsx +++ b/fluxer_app/src/components/channel/dm/DMWelcomeSection.tsx @@ -29,6 +29,7 @@ import {AvatarStack} from '~/components/uikit/avatars/AvatarStack'; import {Button} from '~/components/uikit/Button/Button'; import FocusRing from '~/components/uikit/FocusRing/FocusRing'; import {StatusAwareAvatar} from '~/components/uikit/StatusAwareAvatar'; +import {Tooltip} from '~/components/uikit/Tooltip/Tooltip'; import type {ProfileRecord} from '~/records/ProfileRecord'; import GuildMemberStore from '~/stores/GuildMemberStore'; import MobileLayoutStore from '~/stores/MobileLayoutStore'; @@ -92,6 +93,7 @@ export const DMWelcomeSection: React.FC = observer(functi }; const hasMutualGuilds = mutualGuilds.length > 0; + const currentUserUnclaimed = !(UserStore.currentUser?.isClaimed() ?? true); const shouldShowActionButton = !user.bot && (relationshipType === undefined || @@ -103,12 +105,22 @@ export const DMWelcomeSection: React.FC = observer(functi const renderActionButton = () => { if (user.bot) return null; switch (relationshipType) { - case undefined: - return ( - ); + if (currentUserUnclaimed) { + return ( + +
{button}
+
+ ); + } + return button; + } case RelationshipTypes.INCOMING_REQUEST: return (
diff --git a/fluxer_app/src/components/channel/embeds/media/EmbedGifv.tsx b/fluxer_app/src/components/channel/embeds/media/EmbedGifv.tsx index b1d305bd..b289a103 100644 --- a/fluxer_app/src/components/channel/embeds/media/EmbedGifv.tsx +++ b/fluxer_app/src/components/channel/embeds/media/EmbedGifv.tsx @@ -457,8 +457,8 @@ export const EmbedGifv: FC< const {width, aspectRatio} = style; const containerStyle = { '--embed-width': `${width}px`, - maxWidth: `${width}px`, - width: '100%', + maxWidth: '100%', + width, aspectRatio, } as React.CSSProperties; @@ -488,7 +488,7 @@ export const EmbedGifv: FC< type="gifv" handlePress={openImagePreview} > -
+
{(!loaded || error) && thumbHashURL && ( {t`Loading )} @@ -551,6 +551,7 @@ export const EmbedGif: FC(null); const imgRef = useRef(null); + const isHoveredRef = useRef(false); const defaultName = deriveDefaultNameFromMessage({ message, @@ -622,16 +623,21 @@ export const EmbedGif: FC { if (gifAutoPlay) return; - const img = imgRef.current; const container = containerRef.current; - if (!img || !container) return; + if (!container) return; const handleMouseEnter = () => { - if (FocusManager.isFocused() && img) { - img.src = optimizedAnimatedURL; + isHoveredRef.current = true; + if (FocusManager.isFocused()) { + const img = imgRef.current; + if (img) { + img.src = optimizedAnimatedURL; + } } }; const handleMouseLeave = () => { + isHoveredRef.current = false; + const img = imgRef.current; if (img) { img.src = optimizedStaticURL; } @@ -646,6 +652,24 @@ export const EmbedGif: FC { + if (gifAutoPlay) return; + + const unsubscribe = FocusManager.subscribe((focused) => { + const img = imgRef.current; + if (!img) return; + if (!focused) { + img.src = optimizedStaticURL; + return; + } + if (isHoveredRef.current && focused) { + img.src = optimizedAnimatedURL; + } + }); + + return unsubscribe; + }, [gifAutoPlay, optimizedAnimatedURL, optimizedStaticURL]); + if (shouldBlur) { const {style} = mediaCalculator.calculate({width: naturalWidth, height: naturalHeight}, {forceScale: true}); const {width: _width, height: _height, ...styleWithoutDimensions} = style; @@ -679,8 +703,8 @@ export const EmbedGif: FC -
+
{(!loaded || error) && thumbHashURL && ( {t`Loading )} diff --git a/fluxer_app/src/components/channel/embeds/media/EmbedImage.tsx b/fluxer_app/src/components/channel/embeds/media/EmbedImage.tsx index f3ee4412..891003f0 100644 --- a/fluxer_app/src/components/channel/embeds/media/EmbedImage.tsx +++ b/fluxer_app/src/components/channel/embeds/media/EmbedImage.tsx @@ -251,6 +251,11 @@ export const EmbedImage: FC = observer( aspectRatio: true, }, ); + const resolvedContainerStyle: React.CSSProperties = { + ...containerStyle, + width: dimensions.width, + maxWidth: '100%', + }; const shouldRenderPlaceholder = error || !loaded; @@ -295,7 +300,7 @@ export const EmbedImage: FC = observer(
-
+
{thumbHashURL && (
@@ -328,7 +333,7 @@ export const EmbedImage: FC = observer(
= observer( } : { width: dimensions.width, - maxWidth: dimensions.width, + maxWidth: '100%', aspectRatio, }; diff --git a/fluxer_app/src/components/channel/shared/PickerSearchInput.tsx b/fluxer_app/src/components/channel/shared/PickerSearchInput.tsx index 9b16434b..af981f07 100644 --- a/fluxer_app/src/components/channel/shared/PickerSearchInput.tsx +++ b/fluxer_app/src/components/channel/shared/PickerSearchInput.tsx @@ -79,7 +79,6 @@ export const PickerSearchInput = React.forwardRef { - const _inputElementRef = React.useRef(null); const {t} = useLingui(); const inputElementRef = React.useRef(null); const {canFocus, safeFocusTextarea} = useInputFocusManagement(inputElementRef); diff --git a/fluxer_app/src/components/common/CustomStatusDisplay/CustomStatusDisplay.tsx b/fluxer_app/src/components/common/CustomStatusDisplay/CustomStatusDisplay.tsx index 190572c0..cca3a24e 100644 --- a/fluxer_app/src/components/common/CustomStatusDisplay/CustomStatusDisplay.tsx +++ b/fluxer_app/src/components/common/CustomStatusDisplay/CustomStatusDisplay.tsx @@ -18,14 +18,13 @@ */ import {FloatingPortal} from '@floating-ui/react'; -import {Trans, useLingui} from '@lingui/react/macro'; -import {PencilIcon, SealCheckIcon, SmileyIcon} from '@phosphor-icons/react'; +import {Trans} from '@lingui/react/macro'; +import {PencilIcon, SmileyIcon} from '@phosphor-icons/react'; import {clsx} from 'clsx'; import {AnimatePresence, motion} from 'framer-motion'; import {observer} from 'mobx-react-lite'; import React from 'react'; -import {GuildFeatures} from '~/Constants'; -import {GuildIcon} from '~/components/popouts/GuildIcon'; +import {EmojiAttributionSubtext, getEmojiAttribution} from '~/components/emojis/EmojiAttributionSubtext'; import {EmojiTooltipContent} from '~/components/uikit/EmojiTooltipContent/EmojiTooltipContent'; import FocusRing from '~/components/uikit/FocusRing/FocusRing'; import {useTooltipPortalRoot} from '~/components/uikit/Tooltip'; @@ -35,7 +34,6 @@ import {useReactionTooltip} from '~/hooks/useReactionTooltip'; import {type CustomStatus, getCustomStatusText, normalizeCustomStatus} from '~/lib/customStatus'; import UnicodeEmojis from '~/lib/UnicodeEmojis'; import EmojiStore from '~/stores/EmojiStore'; -import GuildListStore from '~/stores/GuildListStore'; import GuildStore from '~/stores/GuildStore'; import MobileLayoutStore from '~/stores/MobileLayoutStore'; import PresenceStore from '~/stores/PresenceStore'; @@ -128,62 +126,6 @@ const getTooltipEmojiUrl = (status: CustomStatus): string | null => { return null; }; -const StatusEmojiTooltipSubtext = observer(({status}: {status: CustomStatus}) => { - const {t} = useLingui(); - const isCustomEmoji = Boolean(status.emojiId); - - if (!isCustomEmoji) { - return ( - - This is a default emoji on Fluxer. - - ); - } - - 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 ( - - This is a custom emoji from a community. Ask the author for an invite to use this emoji. - - ); - } - - const guild = guildId ? GuildStore.getGuild(guildId) : null; - - if (!guild) { - return ( - - This is a custom emoji from a community. - - ); - } - - const isVerified = guild.features.has(GuildFeatures.VERIFIED); - - return ( -
- - This is a custom emoji from - -
-
- -
- {guild.name} - {isVerified && ( - - - - )} -
-
- ); -}); - interface StatusEmojiWithTooltipProps { status: CustomStatus; children: React.ReactNode; @@ -195,6 +137,13 @@ const StatusEmojiWithTooltip = observer( ({status, children, onClick, isButton = false}: StatusEmojiWithTooltipProps) => { const tooltipPortalRoot = useTooltipPortalRoot(); const {targetRef, tooltipRef, state, updatePosition, handlers, tooltipHandlers} = useReactionTooltip(500); + const emoji = status.emojiId ? EmojiStore.getEmojiById(status.emojiId) : null; + const attribution = getEmojiAttribution({ + emojiId: status.emojiId, + guildId: emoji?.guildId ?? null, + guild: emoji?.guildId ? GuildStore.getGuild(emoji.guildId) : null, + emojiName: status.emojiName, + }); const getEmojiDisplayName = (): string => { if (status.emojiId) { @@ -257,7 +206,18 @@ const StatusEmojiWithTooltip = observer( emoji={shouldUseNativeEmoji && status.emojiName && !status.emojiId ? status.emojiName : undefined} emojiAlt={status.emojiName ?? undefined} primaryContent={emojiName} - subtext={} + subtext={ + + } /> diff --git a/fluxer_app/src/components/emojis/EmojiAttributionSubtext.tsx b/fluxer_app/src/components/emojis/EmojiAttributionSubtext.tsx new file mode 100644 index 00000000..8e22d99f --- /dev/null +++ b/fluxer_app/src/components/emojis/EmojiAttributionSubtext.tsx @@ -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 . + */ + +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 ( +
+ + This is a default emoji on Fluxer. + +
+ ); + } + + if (attribution.type === 'custom_invite_required') { + return ( +
+ + This is a custom emoji from a community. Ask the author for an invite to use this emoji. + +
+ ); + } + + if (attribution.type === 'custom_unknown' || !attribution.guild) { + return ( +
+ + This is a custom emoji from a community. + +
+ ); + } + + return ( +
+ + This is a custom emoji from + +
+
+ +
+ {attribution.guild.name} + {attribution.isVerified && ( + + + + )} +
+
+ ); +}); + +EmojiAttributionSubtext.displayName = 'EmojiAttributionSubtext'; diff --git a/fluxer_app/src/components/emojis/EmojiInfoContent.tsx b/fluxer_app/src/components/emojis/EmojiInfoContent.tsx index abd82e5a..cba306e8 100644 --- a/fluxer_app/src/components/emojis/EmojiInfoContent.tsx +++ b/fluxer_app/src/components/emojis/EmojiInfoContent.tsx @@ -17,14 +17,9 @@ * along with Fluxer. If not, see . */ -import {Trans, useLingui} from '@lingui/react/macro'; -import {SealCheckIcon} from '@phosphor-icons/react'; import {observer} from 'mobx-react-lite'; -import {GuildFeatures} from '~/Constants'; -import {GuildIcon} from '~/components/popouts/GuildIcon'; -import {Tooltip} from '~/components/uikit/Tooltip/Tooltip'; +import {EmojiAttributionSubtext, getEmojiAttribution} from '~/components/emojis/EmojiAttributionSubtext'; import type {Emoji} from '~/stores/EmojiStore'; -import GuildListStore from '~/stores/GuildListStore'; import GuildStore from '~/stores/GuildStore'; import styles from './EmojiInfoContent.module.css'; @@ -33,62 +28,25 @@ interface EmojiInfoContentProps { } export const EmojiInfoContent = observer(function EmojiInfoContent({emoji}: EmojiInfoContentProps) { - const {t} = useLingui(); - const isCustomEmoji = Boolean(emoji.guildId || emoji.id); - - if (!isCustomEmoji) { - return ( -
- - This is a default emoji on Fluxer. - -
- ); - } - - const guildId = emoji.guildId; - const isMember = guildId ? GuildListStore.guilds.some((guild) => guild.id === guildId) : false; - - if (!isMember) { - return ( -
- - This is a custom emoji from a community. Ask the author for an invite to use this emoji. - -
- ); - } - - const guild = guildId ? GuildStore.getGuild(guildId) : null; - - if (!guild) { - return ( -
- - This is a custom emoji from a community. - -
- ); - } - - const isVerified = guild.features.has(GuildFeatures.VERIFIED); + const guild = emoji.guildId ? GuildStore.getGuild(emoji.guildId) : null; + const attribution = getEmojiAttribution({ + emojiId: emoji.id, + guildId: emoji.guildId, + guild, + emojiName: emoji.name, + }); return ( -
- - This is a custom emoji from - -
-
- -
- {guild.name} - {isVerified && ( - - - - )} -
-
+ ); }); diff --git a/fluxer_app/src/components/layout/ChannelItem.module.css b/fluxer_app/src/components/layout/ChannelItem.module.css index 56eaad53..627f7892 100644 --- a/fluxer_app/src/components/layout/ChannelItem.module.css +++ b/fluxer_app/src/components/layout/ChannelItem.module.css @@ -242,6 +242,11 @@ opacity: 0.5; } +.channelItemDisabled { + opacity: 0.6; + cursor: not-allowed; +} + .hoverAffordance { display: none; } diff --git a/fluxer_app/src/components/layout/ChannelItem.tsx b/fluxer_app/src/components/layout/ChannelItem.tsx index 9e830ed7..b7b08c01 100644 --- a/fluxer_app/src/components/layout/ChannelItem.tsx +++ b/fluxer_app/src/components/layout/ChannelItem.tsx @@ -76,6 +76,7 @@ import ReadStateStore from '~/stores/ReadStateStore'; import SelectedChannelStore from '~/stores/SelectedChannelStore'; import TrustedDomainStore from '~/stores/TrustedDomainStore'; import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore'; +import UserStore from '~/stores/UserStore'; import MediaEngineStore from '~/stores/voice/MediaEngineFacade'; import * as ChannelUtils from '~/utils/ChannelUtils'; @@ -194,10 +195,18 @@ export const ChannelItem = observer( const showKeyboardAffordances = keyboardModeEnabled && isFocused; const currentUserId = AuthenticationStore.currentUserId; + const currentUser = UserStore.getCurrentUser(); + const isUnclaimed = !(currentUser?.isClaimed() ?? false); + const isGuildOwner = currentUser ? guild.isOwner(currentUser.id) : false; const currentMember = currentUserId ? GuildMemberStore.getMember(guild.id, currentUserId) : null; const isCurrentUserTimedOut = Boolean(currentMember?.isTimedOut()); + const voiceBlockedForUnclaimed = channelIsVoice && isUnclaimed && !isGuildOwner; const voiceTooltipText = - channelIsVoice && isCurrentUserTimedOut ? t`You can't join while you're on timeout.` : undefined; + channelIsVoice && isCurrentUserTimedOut + ? t`You can't join while you're on timeout.` + : channelIsVoice && voiceBlockedForUnclaimed + ? t`Claim your account to join this voice channel.` + : undefined; const isVoiceSelected = channel.type === ChannelTypes.GUILD_VOICE && @@ -367,6 +376,13 @@ export const ChannelItem = observer( }); return; } + if (channel.type === ChannelTypes.GUILD_VOICE && voiceBlockedForUnclaimed) { + ToastActionCreators.createToast({ + type: 'error', + children: t`Claim your account to join this voice channel.`, + }); + return; + } if (channel.type === ChannelTypes.GUILD_CATEGORY) { onToggle?.(); return; @@ -512,6 +528,7 @@ export const ChannelItem = observer( contextMenuOpen && styles.contextMenuOpen, showKeyboardAffordances && styles.keyboardFocus, channelIsVoice && styles.channelItemVoice, + voiceBlockedForUnclaimed && styles.channelItemDisabled, )} onClick={handleSelect} onContextMenu={handleContextMenu} diff --git a/fluxer_app/src/components/layout/GuildLayout.tsx b/fluxer_app/src/components/layout/GuildLayout.tsx index 97eedc87..476eca5a 100644 --- a/fluxer_app/src/components/layout/GuildLayout.tsx +++ b/fluxer_app/src/components/layout/GuildLayout.tsx @@ -200,6 +200,10 @@ export const GuildLayout = observer(({children}: {children: React.ReactNode}) => if (nagbarState.forceHideInvitesDisabled) return false; if (nagbarState.forceInvitesDisabled) return true; + if (user && !user.isClaimed() && guild.ownerId === user.id) { + return false; + } + const hasInvitesDisabled = guild.features.has(GuildFeatures.INVITES_DISABLED); if (!hasInvitesDisabled) return false; @@ -218,6 +222,7 @@ export const GuildLayout = observer(({children}: {children: React.ReactNode}) => invitesDisabledDismissed, nagbarState.forceInvitesDisabled, nagbarState.forceHideInvitesDisabled, + user, ]); const shouldShowStaffOnlyGuild = React.useMemo(() => { diff --git a/fluxer_app/src/components/layout/GuildsLayout.tsx b/fluxer_app/src/components/layout/GuildsLayout.tsx index e1136de0..e2d97dcd 100644 --- a/fluxer_app/src/components/layout/GuildsLayout.tsx +++ b/fluxer_app/src/components/layout/GuildsLayout.tsx @@ -28,11 +28,9 @@ import {clsx} from 'clsx'; import {observer} from 'mobx-react-lite'; import React from 'react'; import * as DimensionActionCreators from '~/actions/DimensionActionCreators'; -import * as ModalActionCreators from '~/actions/ModalActionCreators'; -import {modal} from '~/actions/ModalActionCreators'; import * as UserSettingsActionCreators from '~/actions/UserSettingsActionCreators'; import {ChannelTypes} from '~/Constants'; -import {ClaimAccountModal} from '~/components/modals/ClaimAccountModal'; +import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal'; import {Tooltip} from '~/components/uikit/Tooltip/Tooltip'; import {ComponentDispatch} from '~/lib/ComponentDispatch'; import {Platform} from '~/lib/Platform'; @@ -330,7 +328,7 @@ export const GuildsLayout = observer(({children}: {children: React.ReactNode}) = if (accountAgeMs < THIRTY_MINUTES_MS) return; NagbarStore.markClaimAccountModalShown(); - ModalActionCreators.push(modal(() => )); + openClaimAccountModal(); }, [isReady, user, location.pathname]); const shouldShowSidebarDivider = !mobileLayout.enabled; diff --git a/fluxer_app/src/components/layout/app-layout/nagbars/UnclaimedAccountNagbar.tsx b/fluxer_app/src/components/layout/app-layout/nagbars/UnclaimedAccountNagbar.tsx index 679363eb..959b09be 100644 --- a/fluxer_app/src/components/layout/app-layout/nagbars/UnclaimedAccountNagbar.tsx +++ b/fluxer_app/src/components/layout/app-layout/nagbars/UnclaimedAccountNagbar.tsx @@ -19,12 +19,10 @@ import {Trans} from '@lingui/react/macro'; import {observer} from 'mobx-react-lite'; -import * as ModalActionCreators from '~/actions/ModalActionCreators'; -import {modal} from '~/actions/ModalActionCreators'; import {Nagbar} from '~/components/layout/Nagbar'; import {NagbarButton} from '~/components/layout/NagbarButton'; import {NagbarContent} from '~/components/layout/NagbarContent'; -import {ClaimAccountModal} from '~/components/modals/ClaimAccountModal'; +import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal'; import UserStore from '~/stores/UserStore'; export const UnclaimedAccountNagbar = observer(({isMobile}: {isMobile: boolean}) => { @@ -34,7 +32,7 @@ export const UnclaimedAccountNagbar = observer(({isMobile}: {isMobile: boolean}) } const handleClaimAccount = () => { - ModalActionCreators.push(modal(() => )); + openClaimAccountModal({force: true}); }; return ( diff --git a/fluxer_app/src/components/modals/ChannelSettingsModal.tsx b/fluxer_app/src/components/modals/ChannelSettingsModal.tsx index f2d5cdb2..797f15cb 100644 --- a/fluxer_app/src/components/modals/ChannelSettingsModal.tsx +++ b/fluxer_app/src/components/modals/ChannelSettingsModal.tsx @@ -24,6 +24,7 @@ import * as ModalActionCreators from '~/actions/ModalActionCreators'; import {ChannelTypes} from '~/Constants'; import * as Modal from '~/components/modals/Modal'; import ChannelStore from '~/stores/ChannelStore'; +import ConnectionStore from '~/stores/gateway/ConnectionStore'; import MobileLayoutStore from '~/stores/MobileLayoutStore'; import {isMobileExperienceEnabled} from '~/utils/mobileExperience'; import { @@ -41,6 +42,7 @@ import type {ChannelSettingsTabType} from './utils/channelSettingsConstants'; export const ChannelSettingsModal: React.FC = observer(({channelId, initialMobileTab}) => { const {t} = useLingui(); const channel = ChannelStore.getChannel(channelId); + const guildId = channel?.guildId; const [selectedTab, setSelectedTab] = React.useState('overview'); const availableTabs = React.useMemo(() => { @@ -59,6 +61,12 @@ export const ChannelSettingsModal: React.FC = observe const mobileNav = useMobileNavigation(initialTab); const {enabled: isMobile} = MobileLayoutStore; + React.useEffect(() => { + if (guildId) { + ConnectionStore.syncGuildIfNeeded(guildId, 'channel-settings-modal'); + } + }, [guildId]); + React.useEffect(() => { if (!channel) { ModalActionCreators.pop(); diff --git a/fluxer_app/src/components/modals/ClaimAccountModal.tsx b/fluxer_app/src/components/modals/ClaimAccountModal.tsx index aab01269..4b36944e 100644 --- a/fluxer_app/src/components/modals/ClaimAccountModal.tsx +++ b/fluxer_app/src/components/modals/ClaimAccountModal.tsx @@ -22,6 +22,7 @@ import {observer} from 'mobx-react-lite'; import {useEffect, useMemo, useState} from 'react'; import {useForm} from 'react-hook-form'; import * as ModalActionCreators from '~/actions/ModalActionCreators'; +import {modal} from '~/actions/ModalActionCreators'; import * as ToastActionCreators from '~/actions/ToastActionCreators'; import * as UserActionCreators from '~/actions/UserActionCreators'; import {Form} from '~/components/form/Form'; @@ -31,6 +32,7 @@ import confirmStyles from '~/components/modals/ConfirmModal.module.css'; import * as Modal from '~/components/modals/Modal'; import {Button} from '~/components/uikit/Button/Button'; import {useFormSubmit} from '~/hooks/useFormSubmit'; +import ModalStore from '~/stores/ModalStore'; interface FormInputs { email: string; @@ -230,3 +232,20 @@ export const ClaimAccountModal = observer(() => { ); }); + +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(() => ), + CLAIM_ACCOUNT_MODAL_KEY, + ); +}; diff --git a/fluxer_app/src/components/modals/GiftAcceptModal.tsx b/fluxer_app/src/components/modals/GiftAcceptModal.tsx index 72779b00..e9d25f44 100644 --- a/fluxer_app/src/components/modals/GiftAcceptModal.tsx +++ b/fluxer_app/src/components/modals/GiftAcceptModal.tsx @@ -23,12 +23,14 @@ import {observer} from 'mobx-react-lite'; import React from 'react'; import * as GiftActionCreators from '~/actions/GiftActionCreators'; import * as ModalActionCreators from '~/actions/ModalActionCreators'; +import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal'; import * as Modal from '~/components/modals/Modal'; import {Button} from '~/components/uikit/Button/Button'; import {Spinner} from '~/components/uikit/Spinner'; import i18n from '~/i18n'; import {UserRecord} from '~/records/UserRecord'; import GiftStore from '~/stores/GiftStore'; +import UserStore from '~/stores/UserStore'; import {getGiftDurationText} from '~/utils/giftUtils'; import styles from './GiftAcceptModal.module.css'; @@ -41,6 +43,7 @@ export const GiftAcceptModal = observer(function GiftAcceptModal({code}: GiftAcc const giftState = GiftStore.gifts.get(code) ?? null; const gift = giftState?.data ?? null; const [isRedeeming, setIsRedeeming] = React.useState(false); + const isUnclaimed = !(UserStore.currentUser?.isClaimed() ?? false); React.useEffect(() => { if (!giftState) { @@ -64,6 +67,10 @@ export const GiftAcceptModal = observer(function GiftAcceptModal({code}: GiftAcc }; const handleRedeem = async () => { + if (isUnclaimed) { + openClaimAccountModal({force: true}); + return; + } setIsRedeeming(true); try { await GiftActionCreators.redeem(i18n, code); @@ -130,6 +137,42 @@ export const GiftAcceptModal = observer(function GiftAcceptModal({code}: GiftAcc const renderGift = () => { const durationText = getGiftDurationText(i18n, gift!); + if (isUnclaimed) { + return ( + <> +
+
+
+ +
+
+

{durationText}

+ {creator && ( + {t`From ${creator.username}#${creator.discriminator}`} + )} + + Claim your account to redeem this gift. + +
+
+
+
+ + +
+ + ); + } return ( <>
diff --git a/fluxer_app/src/components/modals/GuildSettingsModal.tsx b/fluxer_app/src/components/modals/GuildSettingsModal.tsx index 4fa53529..92808e98 100644 --- a/fluxer_app/src/components/modals/GuildSettingsModal.tsx +++ b/fluxer_app/src/components/modals/GuildSettingsModal.tsx @@ -25,6 +25,7 @@ import * as UnsavedChangesActionCreators from '~/actions/UnsavedChangesActionCre import * as Modal from '~/components/modals/Modal'; import GuildSettingsModalStore from '~/stores/GuildSettingsModalStore'; import GuildStore from '~/stores/GuildStore'; +import ConnectionStore from '~/stores/gateway/ConnectionStore'; import MobileLayoutStore from '~/stores/MobileLayoutStore'; import PermissionStore from '~/stores/PermissionStore'; import UnsavedChangesStore from '~/stores/UnsavedChangesStore'; @@ -79,6 +80,10 @@ export const GuildSettingsModal: React.FC = observer( const unsavedChangesStore = UnsavedChangesStore; + React.useEffect(() => { + ConnectionStore.syncGuildIfNeeded(guildId, 'guild-settings-modal'); + }, [guildId]); + React.useEffect(() => { if (!guild) { ModalActionCreators.pop(); diff --git a/fluxer_app/src/components/modals/InviteModal.tsx b/fluxer_app/src/components/modals/InviteModal.tsx index 34bca791..d6f39d81 100644 --- a/fluxer_app/src/components/modals/InviteModal.tsx +++ b/fluxer_app/src/components/modals/InviteModal.tsx @@ -230,6 +230,17 @@ export const InviteModal = observer(({channelId}: {channelId: string}) => { {channel.name}

+ {invitesDisabled && ( +
+ +

+ + 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. + +

+
+ )}
{
) : !showAdvanced ? ( - <> - - - {invitesDisabled && ( -
- -

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

-
- )} - + ) : (
dispatch({type: 'SET_EMAIL', email: e.target.value})} - placeholder="you@example.com" - autoComplete="email" - /> - - - - -
- -
-
- ); - } - - if (state.flowStep === 'verification') { - const codeForValidation = state.verificationCode.trim().toUpperCase(); - const codeLooksValid = VERIFICATION_CODE_REGEX.test(codeForValidation); - - return ( -
- {renderStepHeader( - Enter Verification Code, - We sent a verification code to {state.email}., - )} - - {state.errorMessage && ( -
- {state.errorMessage} -
- )} - -
{ - e.preventDefault(); - void verifyCode(); - }} - > - - dispatch({type: 'SET_VERIFICATION_CODE', code: formatVerificationCodeInput(e.target.value)}) - } - placeholder="ABCD-1234" - autoComplete="one-time-code" - footer={ - - Letters and numbers only. You can paste the code—formatting is automatic. - - } - /> - - -
- -
-
- - - -
- - -
-
- ); - } - - if (state.flowStep === 'details' && state.selectedType) { - const reporterFullName = state.formValues.reporterFullName.trim(); - const reporterCountry = state.formValues.reporterCountry; - const category = state.formValues.category; - - const messageLinkNormalized = normalizeLikelyUrl(state.formValues.messageLink); - const messageLinkOk = state.selectedType !== 'message' ? true : isValidHttpUrl(messageLinkNormalized); - - const userTargetOk = - state.selectedType !== 'user' ? true : Boolean(state.formValues.userId.trim() || state.formValues.userTag.trim()); - - const guildTargetOk = state.selectedType !== 'guild' ? true : Boolean(state.formValues.guildId.trim()); - - const canSubmit = - Boolean(category) && - Boolean(reporterFullName) && - Boolean(reporterCountry) && - messageLinkOk && - userTargetOk && - guildTargetOk; - - return ( -
- {renderStepHeader(Report Details, Please provide the details of your report.)} - -
- Reporting: {reportTypeLabel} - - - Email verified - -
- - {state.errorMessage && ( -
- {state.errorMessage} -
- )} - -
{ - e.preventDefault(); - void handleSubmit(); - }} - > - - 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' && ( - <> - dispatch({type: 'SET_FORM_FIELD', field: 'messageLink', value: e.target.value})} - placeholder="https://fluxer.app/channels/..." - autoComplete="off" - footer={ - !state.formValues.messageLink.trim() ? undefined : !messageLinkOk ? ( - - That doesn't look like a valid URL. - - ) : undefined - } - /> - dispatch({type: 'SET_FORM_FIELD', field: 'messageUserTag', value: e.target.value})} - placeholder="username#1234" - autoComplete="off" - /> - - )} - - {state.selectedType === 'user' && ( - <> - dispatch({type: 'SET_FORM_FIELD', field: 'userId', value: e.target.value})} - placeholder="123456789012345678" - autoComplete="off" - /> - dispatch({type: 'SET_FORM_FIELD', field: 'userTag', value: e.target.value})} - placeholder="username#1234" - autoComplete="off" - footer={ - userTargetOk ? undefined : ( - - Provide at least a user ID or a user tag. - - ) - } - /> - - )} - - {state.selectedType === 'guild' && ( - <> - dispatch({type: 'SET_FORM_FIELD', field: 'guildId', value: e.target.value})} - placeholder="123456789012345678" - autoComplete="off" - footer={ - guildTargetOk ? undefined : ( - - Guild ID is required. - - ) - } - /> - dispatch({type: 'SET_FORM_FIELD', field: 'inviteCode', value: e.target.value})} - placeholder="abcDEF12" - autoComplete="off" - /> - - )} - - dispatch({type: 'SET_FORM_FIELD', field: 'reporterFullName', value: e.target.value})} - placeholder={t`First and last name`} - autoComplete="name" - /> - - - label={t`Country of Residence`} - value={state.formValues.reporterCountry} - options={countryOptions} - onChange={(value) => dispatch({type: 'SET_FORM_FIELD', field: 'reporterCountry', value})} - /> - - dispatch({type: 'SET_FORM_FIELD', field: 'reporterFluxerTag', value: e.target.value})} - placeholder="username#1234" - /> - -