Compare commits
@@ -153,14 +153,38 @@ jobs:
|
||||
|
||||
# Remove só os anexos que esta plataforma vai repor, para os dois jobs
|
||||
# não apagarem o trabalho um do outro.
|
||||
#
|
||||
# Padrão montado em bash puro: a versão anterior interpolava uma
|
||||
# expressão do Actions dentro de um `case`, e quando ela não casou o
|
||||
# laço passou em silêncio — a release ficou com dois latest.yml e o
|
||||
# updater serviu o antigo, dizendo que a versão nova não existia.
|
||||
R=$(api GET "/releases/$ID/assets")
|
||||
if [ "$(code "$R")" = "200" ]; then
|
||||
body "$R" | jq -r '.[] | "\(.id) \(.name)"' | while read -r aid aname; do
|
||||
case "$aname" in
|
||||
${{ runner.os == 'Windows' && '*.exe|latest.yml' || '*.AppImage|*.deb|latest-linux.yml' }})
|
||||
echo "removendo anexo antigo: $aname"
|
||||
api DELETE "/releases/$ID/assets/$aid" > /dev/null ;;
|
||||
esac
|
||||
if [ "$(code "$R")" != "200" ]; then
|
||||
echo "::error::não foi possível listar os anexos — HTTP $(code "$R"): $(body "$R")"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Filtro dentro do jq, comparando o nome direto. A versão anterior
|
||||
# montava um regex e o casava contra "<id> <nome>", onde o `^` de
|
||||
# `^latest\.yml$` ancorava no início da linha — depois do id — e nunca
|
||||
# podia casar. O laço então só removia os .exe e deixava um latest.yml
|
||||
# duplicado, que é o arquivo que decide se há atualização.
|
||||
OLD=$(body "$R" | jq -r --arg os "${RUNNER_OS:-}" '
|
||||
.[]
|
||||
| select(
|
||||
if $os == "Windows"
|
||||
then (.name | endswith(".exe")) or .name == "latest.yml"
|
||||
else (.name | endswith(".AppImage")) or (.name | endswith(".deb")) or .name == "latest-linux.yml"
|
||||
end
|
||||
)
|
||||
| "\(.id) \(.name)"')
|
||||
echo "anexos desta plataforma já na release: $(printf '%s' "$OLD" | grep -c . || true)"
|
||||
if [ -n "$OLD" ]; then
|
||||
printf '%s\n' "$OLD" | while read -r aid aname; do
|
||||
[ -n "$aid" ] || continue
|
||||
echo "removendo anexo antigo: $aname"
|
||||
D=$(api DELETE "/releases/$ID/assets/$aid")
|
||||
[ "$(code "$D")" = "204" ] || echo "::warning::falha ao remover $aname — HTTP $(code "$D")"
|
||||
done
|
||||
fi
|
||||
|
||||
@@ -181,3 +205,17 @@ jobs:
|
||||
done
|
||||
[ "$sent" -gt 0 ] || { echo "::error::o build não produziu instaladores"; exit 1; }
|
||||
echo "$sent arquivo(s) publicados"
|
||||
|
||||
# O updater busca latest.yml pelo nome. Duas cópias com o mesmo nome
|
||||
# fazem o Gitea servir a mais antiga, e a atualização deixa de ser
|
||||
# oferecida — sem erro em lugar nenhum. Falha aqui em vez de publicar
|
||||
# uma release que parece boa e não atualiza.
|
||||
if [ "${RUNNER_OS:-}" = "Windows" ]; then
|
||||
R=$(api GET "/releases/$ID/assets")
|
||||
DUP=$(body "$R" | jq -r '[.[] | select(.name == "latest.yml")] | length')
|
||||
if [ "$DUP" != "1" ]; then
|
||||
echo "::error::a release tem $DUP cópias de latest.yml — o updater serviria a errada"
|
||||
exit 1
|
||||
fi
|
||||
echo "latest.yml: 1 cópia, como esperado"
|
||||
fi
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 25 KiB |
@@ -1,33 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Ebene_2" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 147.46 146.59">
|
||||
<!--
|
||||
Resenhacord app icon: the mark on the app's own night-blue ground (#0b0b10,
|
||||
the theme colour), inside the 22 %-radius rounded square the icon pipeline
|
||||
masks to. A soft violet glow behind the mark keeps the badge from reading as
|
||||
a flat black square at large sizes.
|
||||
-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #1d1d1b;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: url(#Unbenannter_Verlauf_515);
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: url(#Unbenannter_Verlauf_547);
|
||||
}
|
||||
</style>
|
||||
<linearGradient id="Unbenannter_Verlauf_515" data-name="Unbenannter Verlauf 515" x1="62.13" y1="77.74" x2="118.16" y2="77.74" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#fff"/>
|
||||
<stop offset=".95" stop-color="#9a86b8"/>
|
||||
<radialGradient id="glow" cx="512" cy="480" r="420" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#7c6cf6" stop-opacity="0.28"/>
|
||||
<stop offset="1" stop-color="#7c6cf6" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="back" x1="10" y1="12" x2="74" y2="74" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#c4b5fd"/>
|
||||
<stop offset="1" stop-color="#7c6cf6"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="Unbenannter_Verlauf_547" data-name="Unbenannter Verlauf 547" x1="9.21" y1="71.45" x2="135.41" y2="71.45" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#fff"/>
|
||||
<stop offset=".95" stop-color="#9a86b8"/>
|
||||
<linearGradient id="front" x1="42" y1="42" x2="94" y2="94" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#fda4af"/>
|
||||
<stop offset="1" stop-color="#f472b6"/>
|
||||
</linearGradient>
|
||||
<path id="frontBubble" d="M56 42 H80 A14 14 0 0 1 94 56 V68 A14 14 0 0 1 80 82 H76 L88 94 L68 82 H56 A14 14 0 0 1 42 68 V56 A14 14 0 0 1 56 42 Z"/>
|
||||
<mask id="gap" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100">
|
||||
<rect width="100" height="100" fill="#fff"/>
|
||||
<use href="#frontBubble" fill="#000" stroke="#000" stroke-width="7" stroke-linejoin="round"/>
|
||||
</mask>
|
||||
</defs>
|
||||
<g id="Ebene_1-2" data-name="Ebene 1">
|
||||
<rect class="cls-1" x="0" y="0" width="147.46" height="146.59" rx="32.42" ry="32.42"/>
|
||||
<g>
|
||||
<path class="cls-2" d="M100.85,75.32c9.67-3.15,14.27-9.44,14.27-20.12,0-13.48-9.33-21.23-25.52-21.23h-16.13c-7.98,0-11.35,3.48-11.35,12.14v53.82h0c5.77,0,10.46-4.68,10.46-10.46v-7.44c-.06-.33-.1-.68-.09-1.05,0-3.35.07-6.69.12-10.04h-.03v-24.05c0-2.24,1.12-3.6,3.25-3.6h12.99c10.23,0,15.51,4.5,15.51,13.26s-5.62,14.38-15.29,14.38h-6.04c-.04,3.08-.1,6.14-.11,9.22h6.5c11.46,0,17.64,5.51,17.64,16.08s-8.01,16.22-19.36,16.22l-4.36.38h0l-10.57.41c-6.01,0-6.3,8.01-.3,8.25.34.01.68.02,1.03.02h17.37c17.32,0,27.31-8.99,27.31-24.28,0-12.14-5.84-19.56-17.3-21.92Z"/>
|
||||
<path class="cls-3" d="M102.43,119.43c-.64.25-1.29.48-1.93.7l-1.67.39c-.57.14-1.04.17-1.57.26-.51.09-1.02.16-1.51.21-.98.11-1.95.2-2.9.25-.96.07-1.88.12-2.82.16-3.72.14-7.36.14-10.98.07-1.81-.03-3.61-.1-5.41-.16l-2.67-.11c-.83-.03-1.76-.08-2.69-.13l-5.55-.32-.63-.04-.81-.09c-.54-.08-1.08-.16-1.62-.27-1.07-.22-2.13-.5-3.17-.88-2.07-.74-4.05-1.78-5.83-3.11-.9-.66-1.74-1.39-2.53-2.18l-1.91-1.98-7.37-7.7-7.37-7.7c-2.49-2.54-4.81-5.23-7.56-7.52-2.75-2.29-5.15-4.91-7.6-7.49-1.23-1.29-2.4-2.62-3.61-3.93l-.45-.49-.22-.25-.34-.38c-.59-.67-1.07-1.43-1.47-2.23-.77-1.61-1.13-3.41-.99-5.18.11-1.76.72-3.49,1.7-4.96.26-.35.51-.72.81-1.04l.45-.48.25-.25.96-.93,3.83-3.71,7.66-7.42,15.32-14.84,7.66-7.42.99-.95c.4-.38.8-.74,1.23-1.09.85-.69,1.73-1.33,2.67-1.87,1.87-1.11,3.91-1.92,6.02-2.42,1.06-.24,2.13-.4,3.2-.49.54-.04,1.08-.06,1.62-.06h.73s.67.02.67.02l5.33.11,10.66.21,21.32.42,10.72.23c4.1.16,8.16,1.58,11.38,4.07.83.6,1.56,1.31,2.29,2.01.67.76,1.36,1.51,1.93,2.35,1.16,1.65,2.12,3.46,2.7,5.4.59,1.92.97,3.93.94,5.93,0,1.87-.06,3.58-.09,5.38l-.85,42.64-.22,10.68c-.12,3.6-.47,7.31-1.88,10.77-.69,1.72-1.64,3.4-2.98,4.74-1.32,1.36-2.95,2.38-4.68,3.05-1.75.64-3.58.92-5.39,1.06-.9.02-1.82.04-2.71.02-.44-.01-.89,0-1.33-.06-.22-.02-.44-.06-.67-.1l-.33-.07c-.11-.03-.22.01-.33-.26.11-.27.22-.22.33-.26l.33-.07c.22-.04.44-.08.67-.1.44-.05.89-.04,1.33-.06.89-.02,1.75-.07,2.62-.12,1.72-.2,3.42-.54,5-1.18,1.55-.67,2.95-1.64,4.06-2.88,1.13-1.22,1.92-2.74,2.48-4.33,1.16-3.19,1.37-6.71,1.34-10.18l-.2-10.64-.85-42.65c-.04-1.76-.05-3.61-.12-5.28-.04-1.56-.4-3.08-.9-4.52-.5-1.46-1.29-2.79-2.21-4.01-.45-.62-1-1.16-1.52-1.71-.58-.5-1.14-1.02-1.78-1.43-2.48-1.76-5.49-2.67-8.48-2.67l-10.6.19-21.32.42-10.66.21-5.33.11h-.67c-.23.02-.45.02-.6.03-.35,0-.7.04-1.04.08-.69.09-1.38.22-2.05.4-1.34.38-2.61.95-3.77,1.7-.58.37-1.12.8-1.64,1.25-.26.23-.5.47-.74.71l-.9.94-7.36,7.72-14.72,15.43-7.36,7.72-3.68,3.86-.92.96-.22.23s0,.01,0,.01c-.01,0,.01.02,0,.01.02.02.02.02.05.04,0,.01.05,0,.04,0,.02-.02,0-.01.02-.03l.81.83c1.24,1.28,2.5,2.53,3.71,3.82,2.43,2.59,4.91,5.14,7.04,8.02,2.13,2.88,4.69,5.35,7.08,7.98l7.27,7.8,7.27,7.8,1.76,1.89c.46.48.95.92,1.48,1.32,1.06.8,2.27,1.43,3.54,1.88.64.23,1.3.4,1.97.54.33.07.67.12,1.01.17l.51.06c.33.03.46.03.7.04l5.09.27c.84.05,1.69.1,2.63.12l2.65.1c1.76.06,3.51.12,5.25.15,3.48.06,6.96.07,10.34-.17,1.68-.1,3.36-.28,4.9-.57.4-.06.77-.14,1.12-.23.35-.09.76-.16,1.04-.27l.9-.31c.18-.11.36-.22.54-.3l.65-.29c2.43-1.07,5.26.03,6.33,2.46,1.07,2.43-.03,5.26-2.46,6.33-.05.02-.12.05-.17.07Z"/>
|
||||
<rect width="1024" height="1024" rx="225" fill="#0b0b10"/>
|
||||
<rect width="1024" height="1024" rx="225" fill="url(#glow)"/>
|
||||
<g transform="translate(192 192) scale(6.4)">
|
||||
<path mask="url(#gap)" fill="url(#back)"
|
||||
d="M26 12 H58 A16 16 0 0 1 74 28 V44 A16 16 0 0 1 58 60 H34 L16 74 L20 60 H26 A16 16 0 0 1 10 44 V28 A16 16 0 0 1 26 12 Z"/>
|
||||
<use href="#frontBubble" fill="url(#front)"/>
|
||||
<g fill="#fff">
|
||||
<circle cx="58" cy="62" r="3.6"/>
|
||||
<circle cx="68" cy="62" r="3.6"/>
|
||||
<circle cx="78" cy="62" r="3.6"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 2.0 KiB |
@@ -1,14 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Ebene_2" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 123.08 100.14">
|
||||
<!-- Monochrome silhouette of mark.svg for macOS menu-bar templates (alpha only). -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #1d1d1b;
|
||||
}
|
||||
</style>
|
||||
<path id="frontBubble" d="M56 42 H80 A14 14 0 0 1 94 56 V68 A14 14 0 0 1 80 82 H76 L88 94 L68 82 H56 A14 14 0 0 1 42 68 V56 A14 14 0 0 1 56 42 Z"/>
|
||||
<mask id="gap">
|
||||
<rect width="100" height="100" fill="#fff"/>
|
||||
<use href="#frontBubble" fill="#000" stroke="#000" stroke-width="7" stroke-linejoin="round"/>
|
||||
</mask>
|
||||
<mask id="dots">
|
||||
<rect width="100" height="100" fill="#fff"/>
|
||||
<circle cx="58" cy="62" r="3.6" fill="#000"/>
|
||||
<circle cx="68" cy="62" r="3.6" fill="#000"/>
|
||||
<circle cx="78" cy="62" r="3.6" fill="#000"/>
|
||||
</mask>
|
||||
</defs>
|
||||
<g id="Ebene_1-2" data-name="Ebene 1">
|
||||
<path class="cls-1" d="M91.64,53.93c9.67-3.15,14.27-9.44,14.27-20.12,0-13.48-9.33-21.23-25.52-21.23h-16.13c-7.98,0-11.35,3.48-11.35,12.14v53.82h0c5.77,0,10.46-4.68,10.46-10.46v-7.44c-.06-.33-.1-.68-.09-1.05,0-3.35.07-6.69.12-10.04h-.03v-24.05c0-2.24,1.12-3.6,3.25-3.6h12.99c10.23,0,15.51,4.5,15.51,13.26s-5.62,14.38-15.29,14.38h-6.04c-.04,3.08-.1,6.14-.11,9.22h6.5c11.46,0,17.64,5.51,17.64,16.08s-8.01,16.22-19.36,16.22l-4.36.38h0l-10.57.41c-6.01,0-6.3,8.01-.3,8.25.34.01.68.02,1.03.02h17.37c17.32,0,27.31-8.99,27.31-24.28,0-12.14-5.84-19.56-17.3-21.92Z"/>
|
||||
<path class="cls-1" d="M93.21,98.04c-1.58.67-3.48,1.14-5.16,1.36-7.86,1.04-15.77.76-23.61.52-3.06-.04-7.8-.42-10.92-.56-2.12-.13-4.23-.56-6.23-1.28-4.01-1.34-7.48-4.13-10.26-7.27-3.66-3.85-11.08-11.56-14.75-15.4-2.49-2.54-4.81-5.23-7.56-7.52-4.08-3.48-7.59-7.49-11.21-11.42-3.78-3.58-4.71-9.08-1.77-13.49.6-.98,1.66-1.88,2.47-2.69,6.89-6.67,19.79-19.17,26.81-25.96,0,0,7.66-7.42,7.66-7.42,1.47-1.45,3.08-2.88,4.89-3.91C46.83,1.04,50.61,0,54.4,0c.89-.03,5.73.13,6.73.12,0,0,10.66.21,10.66.21,7.02.15,25.23.49,32.04.65,8.24.22,15.88,5.97,18.3,13.83,1.26,3.64.92,7.57.85,11.31,0,0-.85,42.64-.85,42.64-.31,13.92,1.41,31-17.86,30.33-.78,0-1.57-.04-2.33-.23-.11-.03-.22.01-.33-.26.11-.27.22-.22.33-.26,3.35-.48,6.76-.1,9.95-1.53,6.81-2.78,8.04-10.78,7.88-17.39-.14-6.81-.88-45.63-1.05-53.3-.12-3.34.17-6.68-1.03-9.81-1.03-2.88-3-5.41-5.51-7.15-2.48-1.76-5.49-2.67-8.48-2.67,0,0-10.6.19-10.6.19-9.89.2-27.61.55-37.32.74-3.9-.11-7.81,1.33-10.51,4.18-.22.33-7.8,8.1-8.26,8.65-4.86,5.11-17.37,18.21-22.08,23.15-.73.81-4.11,4.27-4.82,5.05,0,0,0,.01,0,.01-.01,0,.01.02,0,.01.02.02.08.07.09.03.02-.02,0-.01.02-.03,0,0,.81.83.81.83,3.71,3.84,7.52,7.57,10.75,11.84,3.79,4.86,10.17,11.13,14.34,15.78,0,0,7.27,7.8,7.27,7.8,1.85,2.11,4.03,4.24,6.79,5.09,2.44.99,6.67.88,9.28,1.08,3.39.17,7.13.31,10.53.37,5.86.03,11.79.22,17.4-1.24,0,0,.9-.31.9-.31.35-.2.82-.44,1.2-.58,6.01-2.4,9.65,6.26,3.7,8.87h0Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
<path mask="url(#gap)" fill="#1d1d1b"
|
||||
d="M26 12 H58 A16 16 0 0 1 74 28 V44 A16 16 0 0 1 58 60 H34 L16 74 L20 60 H26 A16 16 0 0 1 10 44 V28 A16 16 0 0 1 26 12 Z"/>
|
||||
<use href="#frontBubble" fill="#1d1d1b" mask="url(#dots)"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.0 KiB |
@@ -1,28 +1,33 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Ebene_2" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 123.07 100.13">
|
||||
<!--
|
||||
Resenhacord mark: two speech bubbles in conversation, the front one talking.
|
||||
Square viewBox so every raster target (tray, favicon, PWA) scales without
|
||||
letterboxing. The gap between the bubbles is cut with a mask rather than
|
||||
painted, so the mark stays legible on any background and the macOS template
|
||||
(alpha-only) variant keeps the same silhouette.
|
||||
-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: url(#Unbenannter_Verlauf_515);
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: url(#Unbenannter_Verlauf_547);
|
||||
}
|
||||
</style>
|
||||
<linearGradient id="Unbenannter_Verlauf_515" data-name="Unbenannter Verlauf 515" x1="52.91" y1="56.35" x2="108.94" y2="56.35" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#fff"/>
|
||||
<stop offset=".95" stop-color="#9a86b8"/>
|
||||
<linearGradient id="back" x1="10" y1="12" x2="74" y2="74" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#c4b5fd"/>
|
||||
<stop offset="1" stop-color="#7c6cf6"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="Unbenannter_Verlauf_547" data-name="Unbenannter Verlauf 547" x1="0" y1="50.06" x2="126.19" y2="50.06" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#fff"/>
|
||||
<stop offset=".95" stop-color="#9a86b8"/>
|
||||
<linearGradient id="front" x1="42" y1="42" x2="94" y2="94" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#fda4af"/>
|
||||
<stop offset="1" stop-color="#f472b6"/>
|
||||
</linearGradient>
|
||||
<path id="frontBubble" d="M56 42 H80 A14 14 0 0 1 94 56 V68 A14 14 0 0 1 80 82 H76 L88 94 L68 82 H56 A14 14 0 0 1 42 68 V56 A14 14 0 0 1 56 42 Z"/>
|
||||
<mask id="gap">
|
||||
<rect width="100" height="100" fill="#fff"/>
|
||||
<use href="#frontBubble" fill="#000" stroke="#000" stroke-width="7" stroke-linejoin="round"/>
|
||||
</mask>
|
||||
</defs>
|
||||
<g id="Ebene_1-2" data-name="Ebene 1">
|
||||
<g>
|
||||
<path class="cls-1" d="M91.64,53.93c9.67-3.15,14.27-9.44,14.27-20.12,0-13.48-9.33-21.23-25.52-21.23h-16.13c-7.98,0-11.35,3.48-11.35,12.14v53.82h0c5.77,0,10.46-4.68,10.46-10.46v-7.44c-.06-.33-.1-.68-.09-1.05,0-3.35.07-6.69.12-10.04h-.03v-24.05c0-2.24,1.12-3.6,3.25-3.6h12.99c10.23,0,15.51,4.5,15.51,13.26s-5.62,14.38-15.29,14.38h-6.04c-.04,3.08-.1,6.14-.11,9.22h6.5c11.46,0,17.64,5.51,17.64,16.08s-8.01,16.22-19.36,16.22l-4.36.38h0l-10.57.41c-6.01,0-6.3,8.01-.3,8.25.34.01.68.02,1.03.02h17.37c17.32,0,27.31-8.99,27.31-24.28,0-12.14-5.84-19.56-17.3-21.92Z"/>
|
||||
<path class="cls-2" d="M93.21,98.04c-.64.25-1.29.48-1.93.7l-1.67.39c-.57.14-1.04.17-1.57.26-.51.09-1.02.16-1.51.21-.98.11-1.95.2-2.9.25-.96.07-1.88.12-2.82.16-3.72.14-7.36.14-10.98.07-1.81-.03-3.61-.1-5.41-.16l-2.67-.11c-.83-.03-1.76-.08-2.69-.13l-5.55-.32-.63-.04-.81-.09c-.54-.08-1.08-.16-1.62-.27-1.07-.22-2.13-.5-3.17-.88-2.07-.74-4.05-1.78-5.83-3.11-.9-.66-1.74-1.39-2.53-2.18l-1.91-1.98-7.37-7.7-7.37-7.7c-2.49-2.54-4.81-5.23-7.56-7.52-2.75-2.29-5.15-4.91-7.6-7.49-1.23-1.29-2.4-2.62-3.61-3.93l-.45-.49-.22-.25-.34-.38c-.59-.67-1.07-1.43-1.47-2.23-.77-1.61-1.13-3.41-.99-5.18.11-1.76.72-3.49,1.7-4.96.26-.35.51-.72.81-1.04l.45-.48.25-.25.96-.93,3.83-3.71,7.66-7.42,15.32-14.84,7.66-7.42.99-.95c.4-.38.8-.74,1.23-1.09.85-.69,1.73-1.33,2.67-1.87,1.87-1.11,3.91-1.92,6.02-2.42,1.06-.24,2.13-.4,3.2-.49.54-.04,1.08-.06,1.62-.06h.73s.67.02.67.02l5.33.11,10.66.21,21.32.42,10.72.23c4.1.16,8.16,1.58,11.38,4.07.83.6,1.56,1.31,2.29,2.01.67.76,1.36,1.51,1.93,2.35,1.16,1.65,2.12,3.46,2.7,5.4.59,1.92.97,3.93.94,5.93,0,1.87-.06,3.58-.09,5.38l-.85,42.64-.22,10.68c-.12,3.6-.47,7.31-1.88,10.77-.69,1.72-1.64,3.4-2.98,4.74-1.32,1.36-2.95,2.38-4.68,3.05-1.75.64-3.58.92-5.39,1.06-.9.02-1.82.04-2.71.02-.44-.01-.89,0-1.33-.06-.22-.02-.44-.06-.67-.1l-.33-.07c-.11-.03-.22.01-.33-.26.11-.27.22-.22.33-.26l.33-.07c.22-.04.44-.08.67-.1.44-.05.89-.04,1.33-.06.89-.02,1.75-.07,2.62-.12,1.72-.2,3.42-.54,5-1.18,1.55-.67,2.95-1.64,4.06-2.88,1.13-1.22,1.92-2.74,2.48-4.33,1.16-3.19,1.37-6.71,1.34-10.18l-.2-10.64-.85-42.65c-.04-1.76-.05-3.61-.12-5.28-.04-1.56-.4-3.08-.9-4.52-.5-1.46-1.29-2.79-2.21-4.01-.45-.62-1-1.16-1.52-1.71-.58-.5-1.14-1.02-1.78-1.43-2.48-1.76-5.49-2.67-8.48-2.67l-10.6.19-21.32.42-10.66.21-5.33.11h-.67c-.23.02-.45.02-.6.03-.35,0-.7.04-1.04.08-.69.09-1.38.22-2.05.4-1.34.38-2.61.95-3.77,1.7-.58.37-1.12.8-1.64,1.25-.26.23-.5.47-.74.71l-.9.94-7.36,7.72-14.72,15.43-7.36,7.72-3.68,3.86-.92.96-.22.23s0,.01,0,.01c-.01,0,.01.02,0,.01.02.02.02.02.05.04,0,.01.05,0,.04,0,.02-.02,0-.01.02-.03l.81.83c1.24,1.28,2.5,2.53,3.71,3.82,2.43,2.59,4.91,5.14,7.04,8.02,2.13,2.88,4.69,5.35,7.08,7.98l7.27,7.8,7.27,7.8,1.76,1.89c.46.48.95.92,1.48,1.32,1.06.8,2.27,1.43,3.54,1.88.64.23,1.3.4,1.97.54.33.07.67.12,1.01.17l.51.06c.33.03.46.03.7.04l5.09.27c.84.05,1.69.1,2.63.12l2.65.1c1.76.06,3.51.12,5.25.15,3.48.06,6.96.07,10.34-.17,1.68-.1,3.36-.28,4.9-.57.4-.06.77-.14,1.12-.23.35-.09.76-.16,1.04-.27l.9-.31c.18-.11.36-.22.54-.3l.65-.29c2.43-1.07,5.26.03,6.33,2.46,1.07,2.43-.03,5.26-2.46,6.33-.05.02-.12.05-.17.07Z"/>
|
||||
</g>
|
||||
<path mask="url(#gap)" fill="url(#back)"
|
||||
d="M26 12 H58 A16 16 0 0 1 74 28 V44 A16 16 0 0 1 58 60 H34 L16 74 L20 60 H26 A16 16 0 0 1 10 44 V28 A16 16 0 0 1 26 12 Z"/>
|
||||
<use href="#frontBubble" fill="url(#front)"/>
|
||||
<g fill="#fff">
|
||||
<circle cx="58" cy="62" r="3.6"/>
|
||||
<circle cx="68" cy="62" r="3.6"/>
|
||||
<circle cx="78" cy="62" r="3.6"/>
|
||||
</g>
|
||||
</svg>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -21,11 +21,29 @@ código e os commits seguem em inglês, como o resto do repositório.
|
||||
| Spotify por OAuth (atividade com capa e progresso) | `75316b08` | Token só no servidor; `state` assinado; atividades por fonte para não brigar com o detector do Electron |
|
||||
| Menu do próprio nome | `f5451e1b` | Perfil, status e copiar ID. Sem Clips/trocar conta — não existem aqui |
|
||||
| Cronômetro da call | `f5451e1b` | `startedAt` do servidor; zera sozinho porque sala vazia é destruída |
|
||||
| Correções do Spotify (sincronia, sumiço, barra) | `c899253e` | Pausa virou estado; horário do servidor no `ready` corrige relógio |
|
||||
| Soundboard no app desktop | `c899253e` | `window.prompt` não existe no Electron; campo inline no lugar |
|
||||
| Áudio do sistema sem eco | `d525bbb8` `5b848432` | Módulo nativo com isolamento por processo + ganho e preset de música |
|
||||
| Instaladores publicados pelo Gitea | `ce6bba05` | GitHub compila, Gitea distribui; atualização automática ligada |
|
||||
| Fixar mensagens no canal | `4ceb5cd6` | Estado na própria mensagem; painel no cabeçalho; limite de 50 |
|
||||
| Busca: filtros embutidos e tradução | `50a8f12c` | Os filtros já existiam; faltava `de:fulano` e descoberta |
|
||||
| Emojis e figurinhas do servidor | `7d003021` | `:nome:` no markdown sem plugin; figurinha como mensagem inteira |
|
||||
| Fundação de i18n (en + pt-BR) | `688a1335` | `src/i18n/`; pt-BR é parcial e cai para inglês. Aba Idioma nas configurações |
|
||||
| Rebrand: **Resenhacord** + logo nova | (ver git log) | `productName` mudou, `appId` **não** — assim o NSIS troca a instalação antiga no lugar. `userData` migra `Backspace` → `Resenhacord` no primeiro boot (senão todo mundo cairia da sessão). Logo: duas bolhas de conversa, roxo → rosa; fonte em `assets/brand/*.svg`, PNGs e ícones gerados por `pnpm gen-icons` |
|
||||
| Aviso "X fixou uma mensagem" | (ver git log) | Tabela `messages` ganhou `type` (migração 0018); a rota de pin posta uma mensagem de sistema. Dois links: pular pra mensagem e abrir o painel de fixadas |
|
||||
| Menção `@everyone` | (ver git log) | Reusa o token `<@id>`; permissão `MENTION_EVERYONE` nova, concedida ao `@everyone` por padrão e retroativa via backfill no boot. Servidor neutraliza o token de quem não tem a permissão, no envio **e** na edição |
|
||||
| Renomear canal (nome e tópico) | (ver git log) | Rota `PATCH /api/channels/:id` já existia; faltava interface. Normalização em `shared/constants` para servidor e web usarem a mesma regra |
|
||||
| Volume da tela em 50% por padrão | (ver git log) | `DEFAULT_STREAM_VOLUME`; o publicador reforça o áudio do sistema, então o padrão anterior chegava alto demais |
|
||||
| Sons 50% mais baixos | (ver git log) | Só `SFX_BASE_VOLUME` (0.8 → 0.4); os `.ogg` não foram regerados |
|
||||
| Atualização do desktop verificada com mais frequência | (ver git log) | Era só na abertura e de 4 em 4 horas; agora a cada 30 min e ao focar a janela, com trava de 5 min |
|
||||
| Preview de perfil nos participantes da call | (ver git log) | O popout já existia e era aberto de 11 lugares; **nenhum era de voz**. Ligado nas linhas da lista de voz e no nome dos tiles da grade |
|
||||
|
||||
## Já existia no código (verificado, não construir de novo)
|
||||
|
||||
- **Busca com filtros** — servidor (`q`, `from`, `has`, `before`, `after`),
|
||||
client de API e painel no `SearchPopover` já existiam completos. Só faltava
|
||||
tradução, sintaxe embutida (`de:fulano`) e descoberta.
|
||||
|
||||
- **Animação de digitação** — `TypingIndicator.tsx`, três pontos `animate-bounce`
|
||||
escalonados em 0/150/300ms.
|
||||
- **Sons de call/stream** — `SoundController.tsx`, montado no `AppLayout`:
|
||||
@@ -47,12 +65,33 @@ Ordem sugerida: as pequenas primeiro, as grandes uma de cada vez.
|
||||
| # | Feature | Tamanho | Observação técnica |
|
||||
|---|---|---|---|
|
||||
|
||||
## Identidade do perfil (entregue 2026-09-02, pedido: "completo e FODA")
|
||||
|
||||
Uma coluna JSON (`users.profile_style`, migração 0019) normalizada no servidor
|
||||
por `shared/profile.ts`. Aba **Perfil** nas configurações com prévia ao vivo
|
||||
(`ProfilePanel` renderiza o mesmo `ProfileCard` que o popout).
|
||||
|
||||
| Peça | Onde aparece | Detalhe |
|
||||
|---|---|---|
|
||||
| Estilo do nome — 10 fontes × 9 efeitos × 2 cores | popout, perfil completo | Fontes OFL em `public/fonts`; efeitos são CSS (`.name-fx-*`) |
|
||||
| Decoração do avatar — 10 | popout, perfil, editor | SVG inline em `DecoratedAvatar`, sem imagens |
|
||||
| Efeito do perfil — 9 | banner do popout e do perfil | Partículas CSS com posições semeadas (`ProfileEffectLayer`) |
|
||||
| Nameplate — 10 + personalizado | lista de membros | `.nameplate-*` |
|
||||
| Tema (2 cores) | fundo do cartão e banner sem imagem | |
|
||||
| Pronomes, tag (emoji + 2–4 letras) | ao lado do nome; tag também na lista de membros | |
|
||||
| Links (até 5) | popout e aba Sobre | Ícone detectado pelo host; só https |
|
||||
| Quadro: jogo favorito, rotação (5), quero jogar (10), citação | aba Quadro do perfil; tira "Coleção" no popout | Busca na Steam via `/api/games/search` (proxy, cache 10 min); capas só de CDN da Steam |
|
||||
| Emblemas derivados | popout e perfil | admin, dono, fundador (1º mês), veterano (1 ano), OG (6 meses), estiloso — nada é armazenado |
|
||||
|
||||
Fora do escopo desta entrega, candidatos a seguir: perfil por servidor
|
||||
(apelido/avatar por servidor), emblemas concedidos pelo admin, molduras
|
||||
animadas por upload, status com expiração.
|
||||
|
||||
## Pendente — ideias aprovadas
|
||||
|
||||
| Feature | Tamanho | Observação técnica |
|
||||
|---|---|---|
|
||||
| Watch party | Grande | O screen share do LiveKit já existe; falta sincronizar posição de reprodução entre participantes |
|
||||
| Emojis e stickers do grupo | Média | `UPLOAD_DIR` e o pipeline de upload já existem; falta tabela por espaço e resolução no render de mensagem |
|
||||
|
||||
## Regra de idioma (a partir de 2026-08-31)
|
||||
|
||||
@@ -72,22 +111,52 @@ fixas por `t('chave')`. O que faltar cai para o inglês sozinho.
|
||||
- Configurações → Privacidade
|
||||
- Configurações → Conexões (nasceu bilíngue)
|
||||
- Seletor de GIF
|
||||
- Chat: composer e mensagens (placeholder, anexos, responder, reações)
|
||||
- Barra lateral: servidores, canais e menus de contexto
|
||||
- Lista de membros (grupos por cargo, carregamento)
|
||||
- Busca (rótulos, filtros e sintaxe embutida)
|
||||
- Registro de auditoria e Estatísticas (nasceram bilíngues)
|
||||
- Configurações do canal (visão geral, privacidade, exclusão e rótulos de permissão)
|
||||
- Amigos: abas, listas, pedidos, aba de adicionar e cartões de descoberta
|
||||
- Modal de convite (incluindo os motivos de falha por convite)
|
||||
- Lista de membros da DM em grupo (remover e transferir propriedade)
|
||||
- Configurações → Área de trabalho (início automático, atualizações, instância)
|
||||
- Configurações → Voz: dispositivo de saída (completa a aba, junto com a entrada)
|
||||
- Configurações → Atalhos (incluindo os nomes das ações e o aviso de conflito)
|
||||
- Configurações → Conta (perfil, senha, conta desligada da origem, zona de perigo)
|
||||
- Explorar servidores (cartões, entrar, pedir para entrar)
|
||||
- Configurações → Voz: vídeo (prévia, permissões, escolha de câmera) — fecha a aba
|
||||
- Criar canal, categoria e servidor
|
||||
- DMs em grupo (nova DM, adicionar amigos, configurações do grupo, sair)
|
||||
- Entrar num servidor (descoberta, código de convite, conectar e login federado)
|
||||
- Excluir conta, transferir propriedade e a tela de erro geral
|
||||
- Configurações do servidor → Visão geral (identidade, ícone, banner, zona de perigo)
|
||||
- Configurações do servidor → Membros e banimentos
|
||||
- Configurações do servidor → Cargos (incluindo a tabela de permissões)
|
||||
- Configurações da categoria (renomear, privacidade, exceções, excluir)
|
||||
- Configurações da instância → Geral (nome, descoberta, busca de GIFs)
|
||||
- Mensagens de sistema do chat e a prévia delas na barra lateral
|
||||
- Login, registro e a página de convite
|
||||
- Voz: controles, informações de conexão, folha de entrada no celular, aba Voz
|
||||
- Telas do celular (`Mobile*.tsx`)
|
||||
- Navegação das configurações (servidor, usuário e instância) + Descoberta e
|
||||
pedidos de entrada
|
||||
|
||||
Os rótulos de permissão viraram um conjunto único (`perm.*`), usado pelo painel
|
||||
de cargos e pelos editores de exceção de canal e de categoria — antes havia
|
||||
duas cópias da mesma lista.
|
||||
|
||||
### Fila sugerida de tradução
|
||||
|
||||
Mensagens e composer · lista de membros · servidores e canais · amigos e DMs ·
|
||||
modais de convite · configurações restantes · telas de erro
|
||||
Configurações → Instância: registro, federação, transmissão, armazenamento e
|
||||
usuários (os cinco painéis grandes, só o dono vê) · `ConnectedInstances` ·
|
||||
`UserProfileModal` · `MainContent` e `ChannelSidebar` · mensagens de erro
|
||||
vindas de stores e hooks (`instanceStore`, `useLiveKit`, `transferStore`)
|
||||
|
||||
## Correções pendentes
|
||||
|
||||
| Problema | Causa provável | Correção |
|
||||
|---|---|---|
|
||||
| **Enviar som ao soundboard não funciona no app desktop** (funciona no navegador) | `SoundboardPopover` pede o nome do som com `window.prompt`, que o **Electron não implementa** — não abre nada e devolve vazio, então o fluxo aborta em silêncio, sem erro. `window.prompt` aparece em exatamente um lugar no projeto: esse. O resto do código já o evitava | Trocar por um campo de texto dentro do próprio popover (ou um modal reutilizável). Some o prompt e passa a funcionar igual nos dois. **Vale criar o modal de entrada genérico**, já que não existe nenhum e outras features vão precisar |
|
||||
| **Eco absurdo ao compartilhar tela com som** (app desktop) | O `loopback` do Electron captura a mistura de saída do sistema **inteiro**, que inclui o próprio Backspace tocando a voz dos outros. Essa voz volta para eles dentro da transmissão, com atraso. Não é eco acústico — é digital, então **fone não resolve**. As constraints do web (`restrictOwnAudio`) nunca chegam: o handler ignora o `_request` e monta o stream a partir do enum. É também por isso que `shareAudio` já vinha desligado por padrão no app | **Implementado (2026-09-01), não testado.** Trocado por `electron-native-screenshare`: captura com isolamento por processo — só a janela compartilhada (include) ou tudo menos este app (exclude). O PCM vai do processo principal ao renderer por IPC e vira faixa publicada no LiveKit. **Exige gerar instalador novo e reinstalar**, e só pode ser confirmado numa máquina Windows |
|
||||
| **Bloco do Spotify dessincronizado** (mostra a faixa errada por um tempo) | Soma de duas esperas: a consulta roda a cada **20s** (`useSpotifyActivity`) e o envio pelo WebSocket ainda passa por um **debounce de 5s** no `activityStore`. Na pior hipótese os outros veem a música anterior por ~25s | Consultar de novo perto do fim da faixa (a duração é conhecida) em vez de só por intervalo fixo, e encurtar o debounce para esta fonte |
|
||||
| **Bloco do Spotify some sozinho** | Três caminhos apagam a atividade inteira: faixa **pausada** (`is_playing: false` devolve `null`), o **vão entre faixas** (o Spotify responde `204`) e qualquer falha transitória. Some e volta = a piscada que você viu | Manter a última faixa conhecida por alguns segundos antes de apagar, e enviar um estado *pausado* explícito em vez de sumir com o bloco |
|
||||
| **Barra de progresso errada / andando pausada** | Duas causas independentes: (1) o progresso é derivado de carimbos calculados com o relógio do **servidor** e desenhado contra o relógio de **quem olha** — se os relógios divergem, a barra fica deslocada; (2) a barra continua avançando localmente depois que a pessoa pausa, até a próxima consulta | Enviar o horário do servidor junto no payload para o cliente corrigir a diferença, e congelar a barra quando o estado for pausado |
|
||||
|
||||
|
||||
## App desktop — já existia (verificado 2026-08-31)
|
||||
@@ -112,8 +181,6 @@ Escolhidas pelo dono, inspiradas no Discord. **Nenhuma iniciada.**
|
||||
|
||||
| Feature | Tamanho | Observação técnica |
|
||||
|---|---|---|
|
||||
| **Fixar mensagens no canal** | Pequena | Tabela de fixadas por canal + rota de listagem; permissão natural é `MANAGE_MESSAGES` |
|
||||
| **Busca com filtros** | Média | `SearchPopover` já existe; falta filtrar por autor, canal e presença de anexo — e índice no banco, senão fica lento quando o histórico crescer |
|
||||
| **Resposta rápida de voz (soundboard por atalho)** | Pequena | O soundboard já está pronto; falta só marcar favoritos e ligar a atalhos de teclado, sem abrir painel. Cuidado: o limite de repetição do servidor continua valendo e é o que impede virar metralhadora |
|
||||
| **Cargos com ícone e exibição separada** | Média | Cargos e cores já existem; falta ícone, o campo "exibir separadamente" e o agrupamento na lista de membros |
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backspace",
|
||||
"version": "1.1.0",
|
||||
"version": "2.1.0",
|
||||
"private": true,
|
||||
"description": "Open, self-hosted communication platform \u2014 text, voice, video, and federation",
|
||||
"license": "AGPL-3.0-only",
|
||||
|
||||
|
Before Width: | Height: | Size: 364 KiB After Width: | Height: | Size: 364 KiB |
|
Before Width: | Height: | Size: 368 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 955 B After Width: | Height: | Size: 712 B |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 368 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 4.4 KiB |
@@ -1,5 +1,5 @@
|
||||
appId: com.backspace.desktop
|
||||
productName: Backspace
|
||||
productName: Resenhacord
|
||||
artifactName: "${productName}-${version}-${arch}.${ext}"
|
||||
directories:
|
||||
output: dist-electron
|
||||
@@ -36,7 +36,7 @@ publish:
|
||||
- provider: generic
|
||||
url: https://git.resenha.website/devsyncwrld/backspace/releases/download/latest/
|
||||
protocols:
|
||||
- name: Backspace
|
||||
- name: Resenhacord
|
||||
schemes:
|
||||
- backspace
|
||||
mac:
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "@backspace/desktop",
|
||||
"version": "1.1.0",
|
||||
"version": "2.1.0",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"description": "Backspace",
|
||||
"description": "Resenhacord",
|
||||
"author": {
|
||||
"name": "Jannis Braun",
|
||||
"email": "backspace@backspace.chat"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Backspace — Connect</title>
|
||||
<title>Resenhacord — Conectar</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap');
|
||||
|
||||
@@ -270,7 +270,7 @@
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
<h1>Welcome to Backspace</h1>
|
||||
<h1>Bem-vindo ao Resenhacord</h1>
|
||||
<p>Connect to your instance</p>
|
||||
</div>
|
||||
|
||||
@@ -360,7 +360,7 @@
|
||||
// Update header copy to reflect the "switch" intent rather than the
|
||||
// first-run "welcome" framing.
|
||||
document.querySelector('.header h1').textContent = 'Switch instance';
|
||||
document.querySelector('.header p').textContent = 'Connect to a different Backspace instance, or cancel to stay.';
|
||||
document.querySelector('.header p').textContent = 'Conecte-se a outra instância, ou cancele para ficar nesta.';
|
||||
}
|
||||
}
|
||||
init();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Backspace — Recovery</title>
|
||||
<title>Resenhacord — Recuperação</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
@@ -128,7 +128,7 @@
|
||||
<div class="content">
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<h1>Backspace couldn't load.</h1>
|
||||
<h1>O Resenhacord não conseguiu abrir.</h1>
|
||||
<p class="hint" id="hint">Loading…</p>
|
||||
|
||||
<div class="actions">
|
||||
@@ -147,7 +147,7 @@
|
||||
<pre id="diagnostic">loading…</pre>
|
||||
</details>
|
||||
|
||||
<button class="action subtle" id="btn-quit">Quit Backspace</button>
|
||||
<button class="action subtle" id="btn-quit">Fechar o Resenhacord</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -270,7 +270,7 @@
|
||||
|
||||
async function init() {
|
||||
if (!window.backspace) {
|
||||
$('hint').textContent = 'Recovery bridge unavailable. Please quit and reinstall Backspace.';
|
||||
$('hint').textContent = 'A ponte de recuperação não respondeu. Feche e reinstale o Resenhacord.';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 983 B After Width: | Height: | Size: 746 B |
|
Before Width: | Height: | Size: 703 B After Width: | Height: | Size: 474 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 836 B |
@@ -40,24 +40,31 @@ import {
|
||||
import { migrateUserData } from './userDataMigration';
|
||||
|
||||
// Override Electron's package.json-derived app name so userData lives at
|
||||
// "<appData>/Backspace" instead of leaking the monorepo's "@backspace/desktop"
|
||||
// "<appData>/Resenhacord" instead of leaking the monorepo's "@backspace/desktop"
|
||||
// package name. Must run before any app.getPath('userData') consumer.
|
||||
app.setName('Backspace');
|
||||
app.setName('Resenhacord');
|
||||
|
||||
// One-time migration from the historical scoped path. After the move the old
|
||||
// folder is gone, so subsequent launches hit the old-missing no-op branch.
|
||||
// One-time migrations, oldest first, so an install that skipped a release
|
||||
// still walks the whole chain: "@backspace/desktop" → "Backspace" →
|
||||
// "Resenhacord". Each step is a no-op once its source folder is gone, and a
|
||||
// step whose target already has data leaves it alone (`new-populated`), so a
|
||||
// user who already ran the renamed build is never overwritten by stale files.
|
||||
// Without the second step the rename would have logged everyone out: the
|
||||
// instance URL and session live under userData.
|
||||
{
|
||||
const appDataDir = app.getPath('appData');
|
||||
const oldParent = path.join(appDataDir, '@backspace');
|
||||
const result = migrateUserData({
|
||||
oldDir: path.join(oldParent, 'desktop'),
|
||||
newDir: path.join(appDataDir, 'Backspace'),
|
||||
oldParent,
|
||||
});
|
||||
if (result.kind === 'migrated') {
|
||||
console.log(`[userData] migrated ${result.from} → ${result.to}`);
|
||||
} else if (result.kind === 'failed') {
|
||||
console.error('[userData] migration failed:', result.error);
|
||||
const legacyParent = path.join(appDataDir, '@backspace');
|
||||
const steps: { oldDir: string; newDir: string; oldParent: string }[] = [
|
||||
{ oldDir: path.join(legacyParent, 'desktop'), newDir: path.join(appDataDir, 'Backspace'), oldParent: legacyParent },
|
||||
{ oldDir: path.join(appDataDir, 'Backspace'), newDir: path.join(appDataDir, 'Resenhacord'), oldParent: appDataDir },
|
||||
];
|
||||
for (const step of steps) {
|
||||
const result = migrateUserData(step);
|
||||
if (result.kind === 'migrated') {
|
||||
console.log(`[userData] migrated ${result.from} → ${result.to}`);
|
||||
} else if (result.kind === 'failed') {
|
||||
console.error('[userData] migration failed:', result.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,7 +248,7 @@ function applyLoginItemSettings(openAtLogin: boolean, startMinimized: boolean):
|
||||
enabled: openAtLogin,
|
||||
path: process.execPath,
|
||||
args: startMinimized ? ['--hidden'] : [],
|
||||
name: 'Backspace',
|
||||
name: 'Resenhacord',
|
||||
});
|
||||
} else {
|
||||
// Linux: setLoginItemSettings creates ~/.config/autostart/<name>.desktop.
|
||||
@@ -437,7 +444,7 @@ function createWindow(): void {
|
||||
: {}),
|
||||
minWidth: 940,
|
||||
minHeight: 500,
|
||||
title: 'Backspace',
|
||||
title: 'Resenhacord',
|
||||
icon: path.join(__dirname, '..', 'build', 'icon.png'),
|
||||
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'hidden',
|
||||
...(process.platform !== 'darwin' ? {
|
||||
@@ -570,7 +577,7 @@ function createTray(): void {
|
||||
const icon = loadTrayIcon();
|
||||
tray = new Tray(icon);
|
||||
|
||||
tray.setToolTip('Backspace');
|
||||
tray.setToolTip('Resenhacord');
|
||||
// Context menu is set by the recoveryStore subscriber in app.whenReady,
|
||||
// which keeps the menu in sync with update/recovery state.
|
||||
|
||||
@@ -588,7 +595,9 @@ function createTray(): void {
|
||||
|
||||
function showNotification(title: string, body: string, onClick?: () => void): void {
|
||||
if (!Notification.isSupported()) return;
|
||||
const notification = new Notification({ title, body, silent: false });
|
||||
// silent: o som do sistema é o que mais incomoda numa notificação de chat, e
|
||||
// o app toca o seu próprio efeito — que combina com os demais sons dele.
|
||||
const notification = new Notification({ title, body, silent: true });
|
||||
notification.on('click', onClick ?? (() => {
|
||||
mainWindow?.show();
|
||||
mainWindow?.focus();
|
||||
@@ -708,7 +717,7 @@ function registerIpcHandlers(): void {
|
||||
// executableWillLaunchAtLogin to honour Task Manager's StartupApproved state.
|
||||
const osState = app.getLoginItemSettings({ path: process.execPath });
|
||||
const ownEntry = osState.launchItems?.find(
|
||||
(item) => item.name === 'Backspace' || item.path?.toLowerCase() === process.execPath.toLowerCase(),
|
||||
(item) => item.name === 'Resenhacord' || item.name === 'Backspace' || item.path?.toLowerCase() === process.execPath.toLowerCase(),
|
||||
);
|
||||
// When the Run entry exists, its args are the source of truth for startMinimized.
|
||||
// When the entry is absent (autostart is off), there is no OS state to read, so we
|
||||
@@ -743,7 +752,7 @@ function registerIpcHandlers(): void {
|
||||
if (process.platform === 'win32') {
|
||||
const osState = app.getLoginItemSettings({ path: process.execPath });
|
||||
const ownEntry = osState.launchItems?.find(
|
||||
(item) => item.name === 'Backspace' || item.path?.toLowerCase() === process.execPath.toLowerCase(),
|
||||
(item) => item.name === 'Resenhacord' || item.name === 'Backspace' || item.path?.toLowerCase() === process.execPath.toLowerCase(),
|
||||
);
|
||||
currentOpenAtLogin = osState.executableWillLaunchAtLogin ?? false;
|
||||
// See `get-auto-launch-settings`: when the entry is absent, fall back to disk
|
||||
@@ -812,7 +821,14 @@ function initAutoUpdater(): void {
|
||||
|
||||
let updateConfirmed = false;
|
||||
|
||||
// Cadence state. `lastCheckAt` is stamped from the event rather than from
|
||||
// the callers, so a manual "check for updates" from the settings panel
|
||||
// counts too and a focus arriving right after it doesn't check twice.
|
||||
let lastCheckAt = 0;
|
||||
let downloaded = false;
|
||||
|
||||
autoUpdater.on('checking-for-update', () => {
|
||||
lastCheckAt = Date.now();
|
||||
recoveryStore.update({ updateState: 'checking', lastCheckResult: null });
|
||||
updateConfirmed = false;
|
||||
});
|
||||
@@ -834,6 +850,7 @@ function initAutoUpdater(): void {
|
||||
});
|
||||
|
||||
autoUpdater.on('update-downloaded', (info: { version: string }) => {
|
||||
downloaded = true;
|
||||
const version = info.version.slice(0, 32);
|
||||
recoveryStore.update({ updateState: 'downloaded', updateVersion: version });
|
||||
mainWindow?.webContents.send('update-downloaded', { version });
|
||||
@@ -843,7 +860,7 @@ function initAutoUpdater(): void {
|
||||
// (recovery mode) is visible — no need to also fire a native toast.
|
||||
if (!mainWindow?.isFocused()) {
|
||||
showNotification(
|
||||
'Backspace update ready',
|
||||
'Resenhacord update ready',
|
||||
`Click to restart and install version ${version}.`,
|
||||
() => autoUpdater.quitAndInstall(),
|
||||
);
|
||||
@@ -873,17 +890,28 @@ function initAutoUpdater(): void {
|
||||
}
|
||||
});
|
||||
|
||||
// Initial check with 10s delay (existing behavior)
|
||||
setTimeout(() => {
|
||||
updateConfirmed = false;
|
||||
autoUpdater.checkForUpdates().catch(() => {});
|
||||
}, 10_000);
|
||||
// A release published while the app sits open used to go unnoticed until
|
||||
// the next poll — the startup check had long since run, and four hours is
|
||||
// a long time to look at a stale version. Poll more often, and also check
|
||||
// when the user comes back to the window, which is exactly when they would
|
||||
// notice an update anyway.
|
||||
const POLL_INTERVAL_MS = 30 * 60 * 1000;
|
||||
const MIN_GAP_MS = 5 * 60 * 1000; // keeps alt-tabbing off the feed
|
||||
|
||||
// Periodic check every 4 hours (existing behavior)
|
||||
setInterval(() => {
|
||||
const maybeCheck = (): void => {
|
||||
if (downloaded) return; // already staged; nothing left to look for
|
||||
if (Date.now() - lastCheckAt < MIN_GAP_MS) return;
|
||||
updateConfirmed = false;
|
||||
autoUpdater.checkForUpdates().catch(() => {});
|
||||
}, 4 * 60 * 60 * 1000);
|
||||
};
|
||||
|
||||
setTimeout(maybeCheck, 10_000);
|
||||
setInterval(maybeCheck, POLL_INTERVAL_MS);
|
||||
|
||||
// App-level rather than mainWindow-level: the window is destroyed and
|
||||
// recreated when closed to tray and reopened, and a listener bound to the
|
||||
// old instance would go quiet after the first close.
|
||||
app.on('browser-window-focus', maybeCheck);
|
||||
} catch {
|
||||
// Auto-updater unavailable — recovery surface still functions, just
|
||||
// without update affordances. autoUpdaterRef stays null; recovery action
|
||||
@@ -1083,7 +1111,7 @@ if (!gotTheLock) {
|
||||
|
||||
// AGPL-3.0 § 13: native About panel advertises the version + source repo.
|
||||
app.setAboutPanelOptions({
|
||||
applicationName: 'Backspace',
|
||||
applicationName: 'Resenhacord',
|
||||
applicationVersion: app.getVersion(),
|
||||
copyright: `AGPL-3.0-only · Source: ${UPSTREAM_SOURCE_URL}`,
|
||||
website: UPSTREAM_SOURCE_URL,
|
||||
|
||||
@@ -166,7 +166,7 @@ describe('buildTrayMenuTemplate', () => {
|
||||
it('includes Show/Hide/Change Instance/Quit base items', () => {
|
||||
const items = buildTrayMenuTemplate(defaultState());
|
||||
const labels = items.map((i) => i.label);
|
||||
expect(labels).toContain('Show Backspace');
|
||||
expect(labels).toContain('Mostrar o Resenhacord');
|
||||
expect(labels).toContain('Hide');
|
||||
expect(labels).toContain('Change Instance');
|
||||
expect(labels).toContain('Quit');
|
||||
|
||||
@@ -122,7 +122,7 @@ export function buildTrayMenuTemplate(
|
||||
actions?: Partial<MenuActions>,
|
||||
): MenuItemConstructorOptions[] {
|
||||
const items: MenuItemConstructorOptions[] = [
|
||||
{ label: 'Show Backspace', click: actions?.onShow },
|
||||
{ label: 'Mostrar o Resenhacord', click: actions?.onShow },
|
||||
{ label: 'Hide', click: actions?.onHide },
|
||||
{ type: 'separator' },
|
||||
checkForUpdatesItem(state, () => actions?.onCheckForUpdates?.()),
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE `messages` ADD `pinned_at` integer;--> statement-breakpoint
|
||||
ALTER TABLE `messages` ADD `pinned_by` text;--> statement-breakpoint
|
||||
CREATE INDEX `idx_messages_channel_pinned` ON `messages` (`channel_id`,`pinned_at`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_messages_channel_user_created` ON `messages` (`channel_id`,`user_id`,`created_at`);
|
||||
@@ -0,0 +1,26 @@
|
||||
CREATE TABLE `space_emojis` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`space_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`filename` text NOT NULL,
|
||||
`uploader_id` text,
|
||||
`created_at` integer NOT NULL,
|
||||
FOREIGN KEY (`space_id`) REFERENCES `spaces`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`uploader_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `space_stickers` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`space_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`filename` text NOT NULL,
|
||||
`uploader_id` text,
|
||||
`created_at` integer NOT NULL,
|
||||
FOREIGN KEY (`space_id`) REFERENCES `spaces`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`uploader_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `messages` ADD `sticker_id` text;--> statement-breakpoint
|
||||
CREATE INDEX `idx_space_emojis_space` ON `space_emojis` (`space_id`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `idx_space_emojis_space_name` ON `space_emojis` (`space_id`,`name`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_space_stickers_space` ON `space_stickers` (`space_id`);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `messages` ADD `type` text DEFAULT 'user' NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `users` ADD `profile_style` text;
|
||||
@@ -113,6 +113,34 @@
|
||||
"when": 1788194631751,
|
||||
"tag": "0015_young_human_fly",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "6",
|
||||
"when": 1788295165743,
|
||||
"tag": "0016_lyrical_freak",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "6",
|
||||
"when": 1788295936825,
|
||||
"tag": "0017_purple_wildside",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "6",
|
||||
"when": 1788401450986,
|
||||
"tag": "0018_eminent_kid_colt",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "6",
|
||||
"when": 1788402724322,
|
||||
"tag": "0019_glamorous_crusher_hogan",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
||||
import { config } from '../config.js';
|
||||
import * as schema from './schema.js';
|
||||
import { ensureDefaults, backfillOneOnOneDmMembership } from './migrate.js';
|
||||
import { ensureDefaults, backfillOneOnOneDmMembership, grantMentionEveryoneToLegacyRoles } from './migrate.js';
|
||||
import { setWorkerId } from '../utils/snowflake.js';
|
||||
import { createSnapshot } from '../utils/backup.js';
|
||||
import { hasPendingMigrations } from './pendingMigrations.js';
|
||||
@@ -53,6 +53,7 @@ export function initDatabase() {
|
||||
// Recover pre-fix broken 1-on-1 DM threads (deleted partner's membership row
|
||||
// lost before the tombstone fix). Idempotent — safe no-op on every later boot.
|
||||
backfillOneOnOneDmMembership(sqlite);
|
||||
grantMentionEveryoneToLegacyRoles(sqlite);
|
||||
|
||||
// Initialize Snowflake worker ID from persisted value
|
||||
const settings = sqlite.prepare('SELECT worker_id FROM instance_settings WHERE id = 1').get() as { worker_id: number } | undefined;
|
||||
|
||||
@@ -87,3 +87,44 @@ export function backfillOneOnOneDmMembership(db: Database.Database): void {
|
||||
run();
|
||||
if (restored > 0) console.log(`[backfill] restored ${restored} deleted-partner DM membership row(s)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant MENTION_EVERYONE on @everyone roles that predate the bit.
|
||||
*
|
||||
* `DEFAULT_EVERYONE_PERMISSIONS` only applies at space creation, so spaces
|
||||
* created before the bit existed carry a stored value without it — and would
|
||||
* silently have @everyone mentions stripped for every member. This ORs the bit
|
||||
* in once.
|
||||
*
|
||||
* Idempotent by construction: the OR is a no-op when the bit is already set,
|
||||
* and the WHERE clause skips those rows anyway. Deliberately scoped to the
|
||||
* @everyone role (`id = space_id`), so an owner who later denies the
|
||||
* permission is not overruled on the next boot — their denial lives on a
|
||||
* different row.
|
||||
*/
|
||||
export function grantMentionEveryoneToLegacyRoles(db: Database.Database): void {
|
||||
const BIT = 1n << 15n;
|
||||
|
||||
const rows = db.prepare(
|
||||
`SELECT id, permissions FROM roles WHERE id = space_id`
|
||||
).all() as { id: string; permissions: string | null }[];
|
||||
|
||||
const update = db.prepare('UPDATE roles SET permissions = ? WHERE id = ?');
|
||||
let changed = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
let current: bigint;
|
||||
try {
|
||||
current = BigInt(row.permissions ?? '0');
|
||||
} catch {
|
||||
continue; // unparseable value — leave it for a human, do not guess
|
||||
}
|
||||
if ((current & BIT) !== 0n) continue;
|
||||
update.run((current | BIT).toString(), row.id);
|
||||
changed++;
|
||||
}
|
||||
|
||||
if (changed > 0) {
|
||||
console.log(`[defaults] Granted MENTION_EVERYONE to ${changed} @everyone role(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ export const users = sqliteTable('users', {
|
||||
profileUpdatedAt: integer('profile_updated_at'),
|
||||
passwordChangedAt: integer('password_changed_at'),
|
||||
showActivity: integer('show_activity').notNull().default(1),
|
||||
// JSON ProfileStyle (shared/profile.ts), normalised on write. One column,
|
||||
// not twelve: every field is optional, nothing is queried by value, and the
|
||||
// shape keeps growing. Null means defaults.
|
||||
profileStyle: text('profile_style'),
|
||||
federationRegistryUpdatedAt: integer('federation_registry_updated_at').default(0),
|
||||
federationHealPending: integer('federation_heal_pending').default(0),
|
||||
federationHomeOrphaned: integer('federation_home_orphaned').default(0),
|
||||
@@ -81,6 +85,18 @@ export const messages = sqliteTable('messages', {
|
||||
replyToId: text('reply_to_id'),
|
||||
content: text('content'),
|
||||
editedAt: integer('edited_at'),
|
||||
// Pinned state lives on the message rather than in a join table: a message is
|
||||
// pinned in exactly one channel — its own — so a separate table would only
|
||||
// add a join to every pin lookup.
|
||||
pinnedAt: integer('pinned_at'),
|
||||
pinnedBy: text('pinned_by'),
|
||||
// Figurinha enviada como mensagem. `ON DELETE set null` de propósito: apagar
|
||||
// a figurinha do servidor não pode apagar o histórico de quem a usou.
|
||||
stickerId: text('sticker_id'),
|
||||
// 'user' | 'system'. Space channels gained system messages with the pin
|
||||
// notice; DM channels have carried them since group DMs. Same values, same
|
||||
// renderer on the client.
|
||||
type: text('type').notNull().default('user'),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
}, (table) => ({
|
||||
replyToFk: foreignKey({
|
||||
@@ -89,6 +105,10 @@ export const messages = sqliteTable('messages', {
|
||||
}).onDelete('set null'),
|
||||
channelIdx: index('idx_messages_channel_id').on(table.channelId),
|
||||
userIdx: index('idx_messages_user_id').on(table.userId),
|
||||
// Listing a channel's pins, newest first, without scanning its history.
|
||||
pinnedIdx: index('idx_messages_channel_pinned').on(table.channelId, table.pinnedAt),
|
||||
// Filtered search: author within a channel, ordered by recency.
|
||||
searchIdx: index('idx_messages_channel_user_created').on(table.channelId, table.userId, table.createdAt),
|
||||
}));
|
||||
|
||||
export const attachments = sqliteTable('attachments', {
|
||||
@@ -645,3 +665,37 @@ export const soundboardSounds = sqliteTable('soundboard_sounds', {
|
||||
}, (table) => ({
|
||||
spaceIdx: index('idx_soundboard_space').on(table.spaceId),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Emojis próprios de um espaço, referenciados por `:nome:` nas mensagens.
|
||||
*
|
||||
* O nome é único por espaço: `:trollface:` tem de resolver para uma imagem só,
|
||||
* senão o render vira loteria. Espaços diferentes podem repetir o nome.
|
||||
*/
|
||||
export const spaceEmojis = sqliteTable('space_emojis', {
|
||||
id: text('id').primaryKey(),
|
||||
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
filename: text('filename').notNull(),
|
||||
uploaderId: text('uploader_id').references(() => users.id, { onDelete: 'set null' }),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
}, (table) => ({
|
||||
spaceIdx: index('idx_space_emojis_space').on(table.spaceId),
|
||||
nameUnique: uniqueIndex('idx_space_emojis_space_name').on(table.spaceId, table.name),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Figurinhas do espaço. Tabela separada dos emojis de propósito: emoji entra
|
||||
* no meio do texto e figurinha ocupa a mensagem inteira — tamanhos, limites e
|
||||
* caminho de render são diferentes.
|
||||
*/
|
||||
export const spaceStickers = sqliteTable('space_stickers', {
|
||||
id: text('id').primaryKey(),
|
||||
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
filename: text('filename').notNull(),
|
||||
uploaderId: text('uploader_id').references(() => users.id, { onDelete: 'set null' }),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
}, (table) => ({
|
||||
spaceIdx: index('idx_space_stickers_space').on(table.spaceId),
|
||||
}));
|
||||
|
||||
@@ -19,6 +19,7 @@ import { spotifyRoutes } from './routes/spotify.js';
|
||||
import { auditRoutes } from './routes/audit.js';
|
||||
import { statsRoutes } from './routes/stats.js';
|
||||
import { soundboardRoutes } from './routes/soundboard.js';
|
||||
import { expressionRoutes } from './routes/expressions.js';
|
||||
import { closeOrphanedVoiceSessions } from './utils/voiceSessions.js';
|
||||
import { socialRoutes } from './routes/social.js';
|
||||
import { settingsRoutes } from './routes/settings.js';
|
||||
@@ -30,6 +31,7 @@ import { searchRoutes } from './routes/search.js';
|
||||
import { adminRoutes } from './routes/admin.js';
|
||||
import { gifRoutes } from './routes/gif.js';
|
||||
import { federationRoutes } from './routes/federation.js';
|
||||
import { gameRoutes } from './routes/games.js';
|
||||
import { startFederationWorkers, stopFederationWorkers } from './utils/federationWorker.js';
|
||||
import { startBackupWorker, stopBackupWorker } from './utils/backupWorker.js';
|
||||
import './utils/federationRollback.js'; // Side-effect: registers rollback callbacks for outbox terminal failures.
|
||||
@@ -139,6 +141,7 @@ async function main(): Promise<void> {
|
||||
await app.register(auditRoutes);
|
||||
await app.register(statsRoutes);
|
||||
await app.register(soundboardRoutes);
|
||||
await app.register(expressionRoutes);
|
||||
await app.register(socialRoutes);
|
||||
await app.register(settingsRoutes);
|
||||
await app.register(utilRoutes);
|
||||
@@ -148,6 +151,7 @@ async function main(): Promise<void> {
|
||||
await app.register(searchRoutes);
|
||||
await app.register(adminRoutes);
|
||||
await app.register(gifRoutes);
|
||||
await app.register(gameRoutes);
|
||||
await app.register(federationRoutes);
|
||||
await app.register(registerWebSocket);
|
||||
|
||||
|
||||
@@ -15,6 +15,11 @@ import type {
|
||||
Channel,
|
||||
ChannelCategory,
|
||||
} from '@backspace/shared';
|
||||
import {
|
||||
normalizeChannelName,
|
||||
CHANNEL_NAME_MIN_LENGTH,
|
||||
CHANNEL_NAME_MAX_LENGTH,
|
||||
} from '@backspace/shared/src/constants.js';
|
||||
|
||||
function rowToChannel(row: typeof schema.channels.$inferSelect): Channel {
|
||||
return {
|
||||
@@ -190,8 +195,8 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.code(400).send({ error: 'Channel name is required', statusCode: 400 });
|
||||
}
|
||||
|
||||
const trimmedName = name.trim().toLowerCase().replace(/\s+/g, '-');
|
||||
if (trimmedName.length < 1 || trimmedName.length > 100) {
|
||||
const trimmedName = normalizeChannelName(name);
|
||||
if (trimmedName.length < CHANNEL_NAME_MIN_LENGTH || trimmedName.length > CHANNEL_NAME_MAX_LENGTH) {
|
||||
return reply.code(400).send({ error: 'Channel name must be between 1 and 100 characters', statusCode: 400 });
|
||||
}
|
||||
|
||||
@@ -296,8 +301,8 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
|
||||
const updates: Partial<typeof schema.channels.$inferInsert> = {};
|
||||
|
||||
if (name !== undefined) {
|
||||
const trimmedName = name.trim().toLowerCase().replace(/\s+/g, '-');
|
||||
if (trimmedName.length < 1 || trimmedName.length > 100) {
|
||||
const trimmedName = normalizeChannelName(name);
|
||||
if (trimmedName.length < CHANNEL_NAME_MIN_LENGTH || trimmedName.length > CHANNEL_NAME_MAX_LENGTH) {
|
||||
return reply.code(400).send({ error: 'Channel name must be between 1 and 100 characters', statusCode: 400 });
|
||||
}
|
||||
updates.name = trimmedName;
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { authenticate } from '../utils/auth.js';
|
||||
import { hasPermission, isMember } from '../utils/permissions.js';
|
||||
import { PermissionBits } from '@backspace/shared/src/permissions.js';
|
||||
import { generateSnowflake } from '../utils/snowflake.js';
|
||||
|
||||
const MAX_EMOJIS_PER_SPACE = 100;
|
||||
const MAX_STICKERS_PER_SPACE = 50;
|
||||
const MAX_NAME_LENGTH = 32;
|
||||
|
||||
/**
|
||||
* Nomes viram `:nome:` no texto, então só letras, números e sublinhado —
|
||||
* espaço ou dois-pontos dentro do nome tornariam a referência impossível de
|
||||
* delimitar.
|
||||
*/
|
||||
const NAME_PATTERN = /^[a-z0-9_]{2,32}$/;
|
||||
|
||||
function normalizeName(raw: unknown): string | null {
|
||||
if (typeof raw !== 'string') return null;
|
||||
const name = raw.trim().toLowerCase().replace(/\s+/g, '_').slice(0, MAX_NAME_LENGTH);
|
||||
return NAME_PATTERN.test(name) ? name : null;
|
||||
}
|
||||
|
||||
/** O nome do arquivo é chave no diretório de uploads, nunca um caminho. */
|
||||
function validFilename(raw: unknown): raw is string {
|
||||
return typeof raw === 'string' && raw.length > 0
|
||||
&& !raw.includes('/') && !raw.includes('\\') && !raw.includes('..');
|
||||
}
|
||||
|
||||
export async function expressionRoutes(app: FastifyInstance): Promise<void> {
|
||||
// ─── Emojis ──────────────────────────────────────────────────────────────
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/spaces/:id/emojis', { preHandler: authenticate }, async (request, reply) => {
|
||||
if (!isMember(request.params.id, request.userId)) {
|
||||
return reply.code(403).send({ error: 'Not a member of this space', statusCode: 403 });
|
||||
}
|
||||
const rows = getDb().select().from(schema.spaceEmojis)
|
||||
.where(eq(schema.spaceEmojis.spaceId, request.params.id)).all();
|
||||
return reply.code(200).send({ emojis: rows });
|
||||
});
|
||||
|
||||
app.post<{ Params: { id: string }; Body: { name?: string; filename?: string } }>(
|
||||
'/api/spaces/:id/emojis', { preHandler: authenticate }, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_SPACE)) {
|
||||
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
|
||||
}
|
||||
const name = normalizeName(request.body?.name);
|
||||
if (!name) {
|
||||
return reply.code(400).send({ error: 'Name must be 2-32 chars: letters, numbers, underscore', statusCode: 400 });
|
||||
}
|
||||
if (!validFilename(request.body?.filename)) {
|
||||
return reply.code(400).send({ error: 'Invalid filename', statusCode: 400 });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const count = db.select().from(schema.spaceEmojis)
|
||||
.where(eq(schema.spaceEmojis.spaceId, id)).all().length;
|
||||
if (count >= MAX_EMOJIS_PER_SPACE) {
|
||||
return reply.code(409).send({ error: `At most ${MAX_EMOJIS_PER_SPACE} emojis`, statusCode: 409 });
|
||||
}
|
||||
|
||||
const existing = db.select().from(schema.spaceEmojis)
|
||||
.where(and(eq(schema.spaceEmojis.spaceId, id), eq(schema.spaceEmojis.name, name))).get();
|
||||
if (existing) {
|
||||
return reply.code(409).send({ error: `:${name}: already exists in this space`, statusCode: 409 });
|
||||
}
|
||||
|
||||
const row = {
|
||||
id: generateSnowflake(),
|
||||
spaceId: id,
|
||||
name,
|
||||
filename: request.body!.filename!,
|
||||
uploaderId: request.userId,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
db.insert(schema.spaceEmojis).values(row).run();
|
||||
return reply.code(201).send(row);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/emojis/:id', { preHandler: authenticate }, async (request, reply) => {
|
||||
const db = getDb();
|
||||
const emoji = db.select().from(schema.spaceEmojis)
|
||||
.where(eq(schema.spaceEmojis.id, request.params.id)).get();
|
||||
if (!emoji) return reply.code(404).send({ error: 'Emoji not found', statusCode: 404 });
|
||||
if (!hasPermission(request.userId, emoji.spaceId, PermissionBits.MANAGE_SPACE)) {
|
||||
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
|
||||
}
|
||||
db.delete(schema.spaceEmojis).where(eq(schema.spaceEmojis.id, request.params.id)).run();
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
// ─── Stickers ────────────────────────────────────────────────────────────
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/spaces/:id/stickers', { preHandler: authenticate }, async (request, reply) => {
|
||||
if (!isMember(request.params.id, request.userId)) {
|
||||
return reply.code(403).send({ error: 'Not a member of this space', statusCode: 403 });
|
||||
}
|
||||
const rows = getDb().select().from(schema.spaceStickers)
|
||||
.where(eq(schema.spaceStickers.spaceId, request.params.id)).all();
|
||||
return reply.code(200).send({ stickers: rows });
|
||||
});
|
||||
|
||||
app.post<{ Params: { id: string }; Body: { name?: string; filename?: string } }>(
|
||||
'/api/spaces/:id/stickers', { preHandler: authenticate }, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_SPACE)) {
|
||||
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
|
||||
}
|
||||
// Figurinha é escolhida numa grade, não digitada, então o nome é rótulo
|
||||
// e aceita acento e espaço — ao contrário do emoji.
|
||||
const name = typeof request.body?.name === 'string'
|
||||
? request.body.name.trim().slice(0, MAX_NAME_LENGTH) : '';
|
||||
if (!name) return reply.code(400).send({ error: 'Name is required', statusCode: 400 });
|
||||
if (!validFilename(request.body?.filename)) {
|
||||
return reply.code(400).send({ error: 'Invalid filename', statusCode: 400 });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const count = db.select().from(schema.spaceStickers)
|
||||
.where(eq(schema.spaceStickers.spaceId, id)).all().length;
|
||||
if (count >= MAX_STICKERS_PER_SPACE) {
|
||||
return reply.code(409).send({ error: `At most ${MAX_STICKERS_PER_SPACE} stickers`, statusCode: 409 });
|
||||
}
|
||||
|
||||
const row = {
|
||||
id: generateSnowflake(),
|
||||
spaceId: id,
|
||||
name,
|
||||
filename: request.body!.filename!,
|
||||
uploaderId: request.userId,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
db.insert(schema.spaceStickers).values(row).run();
|
||||
return reply.code(201).send(row);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/stickers/:id', { preHandler: authenticate }, async (request, reply) => {
|
||||
const db = getDb();
|
||||
const sticker = db.select().from(schema.spaceStickers)
|
||||
.where(eq(schema.spaceStickers.id, request.params.id)).get();
|
||||
if (!sticker) return reply.code(404).send({ error: 'Sticker not found', statusCode: 404 });
|
||||
if (!hasPermission(request.userId, sticker.spaceId, PermissionBits.MANAGE_SPACE)) {
|
||||
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
|
||||
}
|
||||
db.delete(schema.spaceStickers).where(eq(schema.spaceStickers.id, request.params.id)).run();
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { authenticate } from '../utils/auth.js';
|
||||
import { steamCoverUrl } from '@backspace/shared/src/profile.js';
|
||||
|
||||
/**
|
||||
* Game lookup for profile widgets, backed by Steam's public store search.
|
||||
*
|
||||
* Proxied rather than called from the browser: the store endpoint has no CORS
|
||||
* headers, and going through here means one place to cache, to bound the
|
||||
* query, and to swap the source later without touching clients. No key is
|
||||
* involved — it is the same unauthenticated endpoint the store website uses.
|
||||
*/
|
||||
interface SteamSearchItem { id: number; name: string; tiny_image?: string }
|
||||
interface GameHit { appId: number; name: string; cover: string; header: string }
|
||||
|
||||
const CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
const cache = new Map<string, { at: number; hits: GameHit[] }>();
|
||||
|
||||
export async function gameRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get<{ Querystring: { q?: string } }>('/api/games/search', { preHandler: authenticate }, async (request, reply) => {
|
||||
const q = (request.query.q ?? '').trim().slice(0, 60);
|
||||
if (q.length < 2) return reply.send({ hits: [] });
|
||||
|
||||
const key = q.toLowerCase();
|
||||
const cached = cache.get(key);
|
||||
if (cached && Date.now() - cached.at < CACHE_TTL_MS) return reply.send({ hits: cached.hits });
|
||||
|
||||
try {
|
||||
const url = `https://store.steampowered.com/api/storesearch/?term=${encodeURIComponent(q)}&l=brazilian&cc=BR`;
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(6000), headers: { 'User-Agent': 'Resenhacord/1.0' } });
|
||||
if (!res.ok) return reply.send({ hits: [] });
|
||||
const data = (await res.json()) as { items?: SteamSearchItem[] };
|
||||
const hits: GameHit[] = (data.items ?? []).slice(0, 8).map((it) => ({
|
||||
appId: it.id,
|
||||
name: it.name,
|
||||
cover: steamCoverUrl(it.id),
|
||||
header: `https://cdn.cloudflare.steamstatic.com/steam/apps/${it.id}/header.jpg`,
|
||||
}));
|
||||
cache.set(key, { at: Date.now(), hits });
|
||||
if (cache.size > 500) cache.delete(cache.keys().next().value!);
|
||||
return reply.send({ hits });
|
||||
} catch {
|
||||
// Upstream hiccup is not the user's problem: an empty list lets them
|
||||
// type the title by hand instead.
|
||||
return reply.send({ hits: [] });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq, and, desc, lt, inArray } from 'drizzle-orm';
|
||||
import { eq, and, desc, lt, inArray, isNotNull } from 'drizzle-orm';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { authenticate } from '../utils/auth.js';
|
||||
import { generateSnowflake } from '../utils/snowflake.js';
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '@backspace/shared';
|
||||
import { sanitizeUser } from '../utils/sanitize.js';
|
||||
import { deleteAttachmentFiles } from '../utils/fileCleanup.js';
|
||||
import { mentionsEveryone, stripEveryoneMention } from '@backspace/shared/src/mentions.js';
|
||||
import { fetchEmbedsForMessages, resolveEmbeds, reResolveEmbeds, embedRowToEmbed } from '../utils/embedResolver.js';
|
||||
|
||||
/**
|
||||
@@ -145,6 +146,12 @@ export function buildMessageWithUser(
|
||||
content: message.content,
|
||||
editedAt: message.editedAt,
|
||||
createdAt: message.createdAt,
|
||||
// Carried on every message so the client can show the pin marker without a
|
||||
// second request, and so the pins panel and the timeline agree.
|
||||
pinnedAt: message.pinnedAt,
|
||||
pinnedBy: message.pinnedBy,
|
||||
type: (message.type === 'system' ? 'system' : 'user') as 'user' | 'system',
|
||||
stickerId: message.stickerId,
|
||||
user: sanitizeUser(user),
|
||||
attachments: attachmentRows.map(a => ({
|
||||
id: a.id,
|
||||
@@ -271,7 +278,8 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const { content, attachments: attachmentIds, replyToId } = request.body;
|
||||
const { attachments: attachmentIds, replyToId, stickerId } = request.body;
|
||||
let content = request.body.content;
|
||||
|
||||
const spaceId = getChannelSpaceId(id);
|
||||
if (!spaceId) {
|
||||
@@ -287,18 +295,47 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.code(403).send({ error: 'Missing ATTACH_FILES permission', statusCode: 403 });
|
||||
}
|
||||
|
||||
const db0 = getDb();
|
||||
const db = db0;
|
||||
|
||||
// @everyone is a permission, not a formatting choice. Rather than reject
|
||||
// the message — which loses what the author wrote over one token — the
|
||||
// token is demoted to the plain text they typed, so it posts and reads the
|
||||
// same but rings nobody.
|
||||
if (
|
||||
typeof content === 'string' &&
|
||||
mentionsEveryone(content) &&
|
||||
!hasPermission(request.userId, spaceId, PermissionBits.MENTION_EVERYONE, id)
|
||||
) {
|
||||
content = stripEveryoneMention(content);
|
||||
}
|
||||
|
||||
const hasContent = content && typeof content === 'string' && content.trim().length > 0;
|
||||
const hasAttachments = attachmentIds && attachmentIds.length > 0;
|
||||
|
||||
if (!hasContent && !hasAttachments) {
|
||||
return reply.code(400).send({ error: 'Message must have content or attachments', statusCode: 400 });
|
||||
// Figurinha vale como conteúdo: a mensagem é a figurinha.
|
||||
let sticker = null;
|
||||
if (stickerId) {
|
||||
sticker = db0.select().from(schema.spaceStickers)
|
||||
.where(eq(schema.spaceStickers.id, stickerId)).get() ?? null;
|
||||
if (!sticker) {
|
||||
return reply.code(400).send({ error: 'Sticker not found', statusCode: 400 });
|
||||
}
|
||||
// Só figurinhas do próprio espaço: aceitar de outro vazaria imagem entre
|
||||
// servidores que não têm relação nenhuma.
|
||||
if (sticker.spaceId !== spaceId) {
|
||||
return reply.code(400).send({ error: 'Sticker belongs to another space', statusCode: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasContent && !hasAttachments && !sticker) {
|
||||
return reply.code(400).send({ error: 'Message must have content, attachments or a sticker', statusCode: 400 });
|
||||
}
|
||||
|
||||
if (content && content.length > MAX_MESSAGE_LENGTH) {
|
||||
return reply.code(400).send({ error: `Message content must be ${MAX_MESSAGE_LENGTH} characters or less`, statusCode: 400 });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const messageId = generateSnowflake();
|
||||
const now = Date.now();
|
||||
|
||||
@@ -323,6 +360,7 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
||||
channelId: id,
|
||||
userId: request.userId,
|
||||
replyToId: replyToId || null,
|
||||
stickerId: sticker ? sticker.id : null,
|
||||
content: content?.trim() || null,
|
||||
createdAt: now,
|
||||
}).run();
|
||||
@@ -400,9 +438,21 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.code(403).send({ error: 'You can only edit your own messages', statusCode: 403 });
|
||||
}
|
||||
|
||||
// Same gate as on create — otherwise editing a message is the way around it.
|
||||
let edited = content;
|
||||
if (mentionsEveryone(edited)) {
|
||||
const editSpaceId = getChannelSpaceId(message.channelId);
|
||||
if (
|
||||
!editSpaceId ||
|
||||
!hasPermission(request.userId, editSpaceId, PermissionBits.MENTION_EVERYONE, message.channelId)
|
||||
) {
|
||||
edited = stripEveryoneMention(edited);
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
db.update(schema.messages)
|
||||
.set({ content: content.trim(), editedAt: now })
|
||||
.set({ content: edited.trim(), editedAt: now })
|
||||
.where(eq(schema.messages.id, id))
|
||||
.run();
|
||||
|
||||
@@ -500,4 +550,146 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
return reply.code(200).send({ success: true });
|
||||
});
|
||||
|
||||
// ─── Pins ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Assembles rows into the shape clients expect. Same batching the channel
|
||||
* listing uses — kept in one place so pins and history cannot drift apart in
|
||||
* what they include.
|
||||
*/
|
||||
function assembleMessages(rows: (typeof schema.messages.$inferSelect)[]): MessageWithUser[] {
|
||||
if (rows.length === 0) return [];
|
||||
const db = getDb();
|
||||
const userIds = [...new Set(rows.map(m => m.userId))];
|
||||
const userMap = new Map(
|
||||
db.select().from(schema.users).where(inArray(schema.users.id, userIds)).all().map(u => [u.id, u]),
|
||||
);
|
||||
|
||||
const messageIds = rows.map(m => m.id);
|
||||
const attachmentMap = new Map<string, (typeof schema.attachments.$inferSelect)[]>();
|
||||
for (const att of db.select().from(schema.attachments)
|
||||
.where(inArray(schema.attachments.messageId, messageIds)).all()) {
|
||||
const mid = att.messageId ?? '';
|
||||
if (!attachmentMap.has(mid)) attachmentMap.set(mid, []);
|
||||
attachmentMap.get(mid)!.push(att);
|
||||
}
|
||||
|
||||
const reactionsMap = fetchReactionsForMessages(messageIds);
|
||||
const embedMap = fetchEmbedsForMessages(messageIds);
|
||||
const replyToMap = fetchReplyToMessages(rows);
|
||||
|
||||
return rows.map(m => {
|
||||
const user = userMap.get(m.userId);
|
||||
if (!user) return null;
|
||||
const replyTo = m.replyToId ? (replyToMap.get(m.replyToId) ?? null) : null;
|
||||
return buildMessageWithUser(m, user, attachmentMap.get(m.id) ?? [],
|
||||
reactionsMap.get(m.id) ?? [], replyTo, embedMap.get(m.id) ?? []);
|
||||
}).filter((m): m is MessageWithUser => m !== null);
|
||||
}
|
||||
|
||||
/** Discord caps a channel at 50; the same ceiling keeps the panel usable. */
|
||||
const MAX_PINS_PER_CHANNEL = 50;
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/channels/:id/pins', { preHandler: authenticate }, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const spaceId = getChannelSpaceId(id);
|
||||
if (!spaceId) return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
|
||||
if (!hasPermission(request.userId, spaceId, PermissionBits.VIEW_CHANNEL, id)) {
|
||||
return reply.code(403).send({ error: 'Cannot view this channel', statusCode: 403 });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const rows = db.select()
|
||||
.from(schema.messages)
|
||||
.where(and(eq(schema.messages.channelId, id), isNotNull(schema.messages.pinnedAt)))
|
||||
.orderBy(desc(schema.messages.pinnedAt))
|
||||
.all();
|
||||
|
||||
return reply.code(200).send({ messages: assembleMessages(rows) });
|
||||
});
|
||||
|
||||
app.put<{ Params: { id: string } }>('/api/messages/:id/pin', { preHandler: authenticate }, async (request, reply) => {
|
||||
const db = getDb();
|
||||
const message = db.select().from(schema.messages).where(eq(schema.messages.id, request.params.id)).get();
|
||||
if (!message) return reply.code(404).send({ error: 'Message not found', statusCode: 404 });
|
||||
|
||||
const spaceId = getChannelSpaceId(message.channelId);
|
||||
if (!spaceId) return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
|
||||
if (!hasPermission(request.userId, spaceId, PermissionBits.MANAGE_MESSAGES, message.channelId)) {
|
||||
return reply.code(403).send({ error: 'Missing MANAGE_MESSAGES permission', statusCode: 403 });
|
||||
}
|
||||
if (message.pinnedAt) return reply.code(200).send({ success: true });
|
||||
|
||||
const count = db.select({ id: schema.messages.id })
|
||||
.from(schema.messages)
|
||||
.where(and(eq(schema.messages.channelId, message.channelId), isNotNull(schema.messages.pinnedAt)))
|
||||
.all().length;
|
||||
if (count >= MAX_PINS_PER_CHANNEL) {
|
||||
return reply.code(409).send({ error: `At most ${MAX_PINS_PER_CHANNEL} pinned messages per channel`, statusCode: 409 });
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
db.update(schema.messages)
|
||||
.set({ pinnedAt: now, pinnedBy: request.userId })
|
||||
.where(eq(schema.messages.id, message.id)).run();
|
||||
|
||||
connectionManager.sendToChannel(spaceId, message.channelId, {
|
||||
type: 'message_pinned',
|
||||
channelId: message.channelId,
|
||||
messageId: message.id,
|
||||
pinned: true,
|
||||
});
|
||||
|
||||
// A pin is a channel event, not just a flag on one row: post a system
|
||||
// notice so everyone reading later knows something was pinned, and by whom.
|
||||
// Same shape as the DM lifecycle notices — JSON payload, type 'system' —
|
||||
// so the client renders it with the renderer it already has. Unpinning
|
||||
// deliberately posts nothing; it is housekeeping, not news.
|
||||
const actor = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
||||
if (actor) {
|
||||
const noticeId = generateSnowflake();
|
||||
db.insert(schema.messages).values({
|
||||
id: noticeId,
|
||||
channelId: message.channelId,
|
||||
userId: request.userId,
|
||||
content: JSON.stringify({ event: 'message_pinned', messageId: message.id }),
|
||||
type: 'system',
|
||||
createdAt: now + 1, // strictly after the pin timestamp, so ordering is stable
|
||||
}).run();
|
||||
const noticeRow = db.select().from(schema.messages).where(eq(schema.messages.id, noticeId)).get();
|
||||
if (noticeRow) {
|
||||
connectionManager.sendToChannel(spaceId, message.channelId, {
|
||||
type: 'message_created',
|
||||
message: buildMessageWithUser(noticeRow, actor, [], [], null),
|
||||
});
|
||||
}
|
||||
}
|
||||
return reply.code(200).send({ success: true });
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/messages/:id/pin', { preHandler: authenticate }, async (request, reply) => {
|
||||
const db = getDb();
|
||||
const message = db.select().from(schema.messages).where(eq(schema.messages.id, request.params.id)).get();
|
||||
if (!message) return reply.code(404).send({ error: 'Message not found', statusCode: 404 });
|
||||
|
||||
const spaceId = getChannelSpaceId(message.channelId);
|
||||
if (!spaceId) return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
|
||||
if (!hasPermission(request.userId, spaceId, PermissionBits.MANAGE_MESSAGES, message.channelId)) {
|
||||
return reply.code(403).send({ error: 'Missing MANAGE_MESSAGES permission', statusCode: 403 });
|
||||
}
|
||||
|
||||
db.update(schema.messages)
|
||||
.set({ pinnedAt: null, pinnedBy: null })
|
||||
.where(eq(schema.messages.id, message.id)).run();
|
||||
|
||||
connectionManager.sendToChannel(spaceId, message.channelId, {
|
||||
type: 'message_pinned',
|
||||
channelId: message.channelId,
|
||||
messageId: message.id,
|
||||
pinned: false,
|
||||
});
|
||||
return reply.code(200).send({ success: true });
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { config } from '../config.js';
|
||||
import { buildFederationHeaders, getOurOrigin } from '../utils/federationAuth.js';
|
||||
import { extractDomain } from './federation.js';
|
||||
import path from 'path';
|
||||
import { normalizeProfileStyle } from '@backspace/shared/src/profile.js';
|
||||
|
||||
/** Validates that a URL is a safe asset URL (relative upload path, bare filename, or http/https) */
|
||||
export function isValidAssetUrl(url: string | null | undefined): boolean {
|
||||
@@ -187,7 +188,7 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
||||
});
|
||||
|
||||
app.patch<{ Body: UpdateUserRequest }>('/api/users/@me', { preHandler: authenticate }, async (request, reply) => {
|
||||
const { displayName, avatar, banner, accentColor, avatarColor, bio, customStatus, status, replicatedInstances, homeUserId, profileUpdatedAt, discoverable, showActivity } = request.body;
|
||||
const { displayName, avatar, banner, accentColor, avatarColor, bio, customStatus, status, replicatedInstances, homeUserId, profileUpdatedAt, discoverable, showActivity, profileStyle } = request.body;
|
||||
const db = getDb();
|
||||
|
||||
const updateData: Record<string, string | null | undefined> = {};
|
||||
@@ -426,12 +427,22 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
||||
(updateData as Record<string, unknown>).showActivity = showActivity ? 1 : 0;
|
||||
}
|
||||
|
||||
// Profile style is normalised, never rejected: the shared normaliser
|
||||
// clamps lengths, drops bad URLs and falls back on unknown ids, so the
|
||||
// stored value is always well-formed. Null resets to defaults. Kept out of
|
||||
// DURABLE_PROFILE_FIELDS on purpose — peers on older builds do not know
|
||||
// the field, and it is cosmetic, so it stays local to this instance.
|
||||
if (profileStyle !== undefined) {
|
||||
(updateData as Record<string, unknown>).profileStyle =
|
||||
profileStyle === null ? null : JSON.stringify(normalizeProfileStyle(profileStyle));
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
|
||||
}
|
||||
|
||||
// Detect profile changes and stamp timestamp
|
||||
const hasProfileChange = ['displayName', 'avatar', 'banner', 'accentColor', 'avatarColor', 'bio', 'customStatus'].some(f => f in updateData);
|
||||
const hasProfileChange = ['displayName', 'avatar', 'banner', 'accentColor', 'avatarColor', 'bio', 'customStatus', 'profileStyle'].some(f => f in updateData);
|
||||
const hasDurableChange = DURABLE_PROFILE_FIELDS.some(f => f in updateData);
|
||||
if (hasDurableChange) {
|
||||
(updateData as Record<string, unknown>).profileUpdatedAt = Date.now();
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { User, ReplicatedInstance } from '@backspace/shared';
|
||||
import { normalizeProfileStyle } from '@backspace/shared/src/profile.js';
|
||||
import { schema } from '../db/index.js';
|
||||
|
||||
function safeParse(json: string): unknown {
|
||||
try { return JSON.parse(json); } catch { return null; }
|
||||
}
|
||||
|
||||
export function sanitizeUser(row: typeof schema.users.$inferSelect, isSelf = false): User {
|
||||
// Tombstoned (deleted) users — return anonymized profile
|
||||
if (row.isDeleted === 1) {
|
||||
@@ -54,6 +59,9 @@ export function sanitizeUser(row: typeof schema.users.$inferSelect, isSelf = fal
|
||||
homeInstance: row.homeInstance ?? null,
|
||||
homeUserId: row.homeUserId ?? null,
|
||||
replicatedInstances,
|
||||
// Re-normalised on read as well: a row written by an older build (or a
|
||||
// stricter future one) still comes out complete and bounded.
|
||||
profileStyle: row.profileStyle ? normalizeProfileStyle(safeParse(row.profileStyle)) : null,
|
||||
...(isSelf
|
||||
? {
|
||||
showActivity: row.showActivity !== 0,
|
||||
|
||||
@@ -16,7 +16,11 @@
|
||||
"./src/constants": "./src/constants.ts",
|
||||
"./src/constants.js": "./src/constants.ts",
|
||||
"./src/audit": "./src/audit.ts",
|
||||
"./src/audit.js": "./src/audit.ts"
|
||||
"./src/audit.js": "./src/audit.ts",
|
||||
"./src/mentions": "./src/mentions.ts",
|
||||
"./src/mentions.js": "./src/mentions.ts",
|
||||
"./src/profile": "./src/profile.ts",
|
||||
"./src/profile.js": "./src/profile.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
|
||||
@@ -45,3 +45,20 @@ export const GROUP_DM_NAME_MAX_LENGTH = 50;
|
||||
export const GROUP_DM_NAME_MIN_LENGTH = 1;
|
||||
export const GROUP_DM_ICON_MAX_BYTES = 8 * 1024 * 1024; // 8 MB
|
||||
export const GROUP_DM_ICON_MIME_PREFIX = 'image/';
|
||||
|
||||
// ─── Channel Names ───────────────────────────────────────────────────────────
|
||||
|
||||
export const CHANNEL_NAME_MIN_LENGTH = 1;
|
||||
export const CHANNEL_NAME_MAX_LENGTH = 100;
|
||||
|
||||
/**
|
||||
* Canonical channel-name form: trimmed, lowercased, runs of whitespace folded
|
||||
* into single hyphens.
|
||||
*
|
||||
* The server applies this before storing, so the client uses the same function
|
||||
* to show what a name will actually become. Two copies of the rule would drift;
|
||||
* one that lives here cannot.
|
||||
*/
|
||||
export function normalizeChannelName(name: string): string {
|
||||
return name.trim().toLowerCase().replace(/\s+/g, '-');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* The @everyone mention.
|
||||
*
|
||||
* It rides the existing `<@id>` token shape — `everyone` matches the same
|
||||
* `[a-zA-Z0-9_-]+` the user-mention parser already accepts — so the markdown
|
||||
* pre-processor, the inline renderer and the badge all pick it up without a
|
||||
* second syntax. User ids are snowflakes (digits only), so the literal
|
||||
* `everyone` can never collide with a real one.
|
||||
*/
|
||||
export const EVERYONE_MENTION_ID = 'everyone';
|
||||
export const EVERYONE_MENTION_TOKEN = `<@${EVERYONE_MENTION_ID}>`;
|
||||
|
||||
/** Matches code spans and fences first, so their contents are never a ping. */
|
||||
const CODE_OR_EVERYONE = /(```[\s\S]*?```|`[^`\n]*`)|<@everyone>/g;
|
||||
|
||||
/**
|
||||
* Whether a message pings everyone.
|
||||
*
|
||||
* A token inside a code span or fenced block does not count: people paste
|
||||
* message payloads and snippets into chat, and a paste should not ring
|
||||
* everybody's client.
|
||||
*/
|
||||
export function mentionsEveryone(content: string | null | undefined): boolean {
|
||||
if (!content) return false;
|
||||
CODE_OR_EVERYONE.lastIndex = 0;
|
||||
for (let m = CODE_OR_EVERYONE.exec(content); m; m = CODE_OR_EVERYONE.exec(content)) {
|
||||
if (!m[1]) return true; // matched the token itself, not a code span
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Neutralises the token into plain text, leaving code spans untouched.
|
||||
*
|
||||
* The server applies this when the author lacks MENTION_EVERYONE. Rejecting the
|
||||
* message instead would lose what they wrote over a formatting detail; this way
|
||||
* the message still posts, and reads as the literal text they typed.
|
||||
*/
|
||||
export function stripEveryoneMention(content: string): string {
|
||||
return content.replace(CODE_OR_EVERYONE, (match, codeBlock: string | undefined) =>
|
||||
codeBlock ? codeBlock : '@everyone',
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export const PermissionBits = {
|
||||
ATTACH_FILES: 1n << 12n,
|
||||
READ_MESSAGE_HISTORY: 1n << 13n,
|
||||
ADD_REACTIONS: 1n << 14n,
|
||||
MENTION_EVERYONE: 1n << 15n,
|
||||
CONNECT: 1n << 20n,
|
||||
SPEAK: 1n << 21n,
|
||||
MUTE_MEMBERS: 1n << 22n,
|
||||
@@ -38,6 +39,10 @@ export const DEFAULT_EVERYONE_PERMISSIONS =
|
||||
PermissionBits.ATTACH_FILES |
|
||||
PermissionBits.READ_MESSAGE_HISTORY |
|
||||
PermissionBits.ADD_REACTIONS |
|
||||
// Granted by default: on a small instance the ping is a convenience, not a
|
||||
// weapon. An owner who disagrees can deny it on @everyone and grant it to a
|
||||
// role instead — which is why it is a bit rather than being ungated.
|
||||
PermissionBits.MENTION_EVERYONE |
|
||||
PermissionBits.STREAM;
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Profile identity: everything a user can style about how they appear, beyond
|
||||
* the avatar/banner/bio fields that predate it.
|
||||
*
|
||||
* Stored as one JSON column (`users.profile_style`) rather than a dozen
|
||||
* columns: the shape will keep growing (new effects, new widgets), every field
|
||||
* is optional, and nothing here is ever queried by value. The server
|
||||
* normalises on write with `normalizeProfileStyle`, so what reaches clients is
|
||||
* always a complete, bounded, well-formed object.
|
||||
*
|
||||
* Catalog ids are strings on purpose — a client that does not know an id
|
||||
* (older build, other instance) falls back to `none`/`default` instead of
|
||||
* crashing on an enum it has never seen.
|
||||
*/
|
||||
|
||||
export const NAME_FONTS = [
|
||||
'default', 'display', 'comic', 'script', 'pixel', 'future', 'serif', 'mono', 'round', 'marker',
|
||||
'techno', 'block', 'retro',
|
||||
] as const;
|
||||
export type NameFont = (typeof NAME_FONTS)[number];
|
||||
|
||||
export const NAME_EFFECTS = [
|
||||
'solid', 'gradient', 'chrome', 'gold', 'holo', 'neon', 'toon', 'pop', 'gummy',
|
||||
'prism', 'glitch', 'fire', 'ice', 'shadow', 'outline', 'rainbow',
|
||||
] as const;
|
||||
export type NameEffect = (typeof NAME_EFFECTS)[number];
|
||||
|
||||
export const AVATAR_DECORATIONS = [
|
||||
'none', 'ring', 'neon', 'cd', 'chain', 'cyber', 'pixel', 'flames', 'lightning', 'spikes',
|
||||
'frost', 'stars', 'hearts', 'butterfly', 'sakura', 'vines', 'bubbles', 'crown', 'halo', 'flower',
|
||||
] as const;
|
||||
export type AvatarDecoration = (typeof AVATAR_DECORATIONS)[number];
|
||||
|
||||
export const PROFILE_EFFECTS = [
|
||||
'none', 'sparkles', 'rain', 'snow', 'matrix', 'confetti', 'aurora', 'embers', 'bubbles', 'hearts',
|
||||
] as const;
|
||||
export type ProfileEffect = (typeof PROFILE_EFFECTS)[number];
|
||||
|
||||
export const NAMEPLATES = [
|
||||
'none', 'sunset', 'ocean', 'neon', 'forest', 'royal', 'brasil', 'cyber', 'blossom', 'midnight',
|
||||
'holo', 'chrome', 'vapor', 'matrix', 'ember', 'custom',
|
||||
] as const;
|
||||
export type Nameplate = (typeof NAMEPLATES)[number];
|
||||
|
||||
export interface NameStyle {
|
||||
font: NameFont;
|
||||
effect: NameEffect;
|
||||
/** Two hex colours. Solid uses the first; every other effect uses both. */
|
||||
colors: [string, string];
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the banner image sits inside its frame.
|
||||
*
|
||||
* A banner is cropped by the frame, and until now the crop was always dead
|
||||
* centre — fine for a photo shot for it, wrong for anything else, and there was
|
||||
* no way to fix it for a GIF, which cannot go through the still-image cropper.
|
||||
* `x`/`y` are CSS object-position percentages; `zoom` scales the image inside
|
||||
* the frame, so together they are a pan-and-zoom crop that costs nothing at
|
||||
* render time and keeps the original file untouched.
|
||||
*/
|
||||
export interface BannerPosition {
|
||||
/** 0–100. 50 is centred. */
|
||||
x: number;
|
||||
y: number;
|
||||
/** 100–300 (%). 100 fills the frame. */
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
export interface ProfileTheme {
|
||||
/** Two hex colours behind the card and the banner fallback. */
|
||||
colors: [string, string];
|
||||
}
|
||||
|
||||
export interface ProfileTag {
|
||||
/** 2–4 characters, shown uppercase in a chip next to the name. */
|
||||
text: string;
|
||||
/** Single emoji (grapheme) in front of the text; empty for none. */
|
||||
emoji: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface ProfileLink {
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface GameEntry {
|
||||
/** Steam app id when picked from search; null for a free-typed title. */
|
||||
appId: number | null;
|
||||
name: string;
|
||||
/** https URL of a cover image (Steam CDN when picked from search). */
|
||||
cover: string | null;
|
||||
note: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface ProfileQuote {
|
||||
text: string;
|
||||
author: string;
|
||||
}
|
||||
|
||||
export interface ProfileWidgets {
|
||||
favoriteGame: GameEntry | null;
|
||||
rotation: GameEntry[];
|
||||
wishlist: GameEntry[];
|
||||
quote: ProfileQuote | null;
|
||||
}
|
||||
|
||||
export interface ProfileStyle {
|
||||
name: NameStyle;
|
||||
theme: ProfileTheme | null;
|
||||
banner: BannerPosition;
|
||||
decoration: AvatarDecoration;
|
||||
effect: ProfileEffect;
|
||||
nameplate: Nameplate;
|
||||
/** Only read when nameplate === 'custom'. */
|
||||
nameplateColors: [string, string];
|
||||
pronouns: string;
|
||||
tag: ProfileTag | null;
|
||||
links: ProfileLink[];
|
||||
widgets: ProfileWidgets;
|
||||
}
|
||||
|
||||
export const PROFILE_LIMITS = {
|
||||
pronouns: 40,
|
||||
tagMin: 2,
|
||||
tagMax: 4,
|
||||
links: 5,
|
||||
linkLabel: 24,
|
||||
linkUrl: 200,
|
||||
gameName: 80,
|
||||
gameNote: 80,
|
||||
gameTags: 3,
|
||||
gameTag: 16,
|
||||
rotation: 5,
|
||||
wishlist: 10,
|
||||
quoteText: 140,
|
||||
quoteAuthor: 40,
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_BANNER_POSITION: BannerPosition = { x: 50, y: 50, zoom: 100 };
|
||||
|
||||
export const DEFAULT_PROFILE_STYLE: ProfileStyle = {
|
||||
name: { font: 'default', effect: 'solid', colors: ['#efefef', '#7c6cf6'] },
|
||||
theme: null,
|
||||
banner: DEFAULT_BANNER_POSITION,
|
||||
decoration: 'none',
|
||||
effect: 'none',
|
||||
nameplate: 'none',
|
||||
nameplateColors: ['#7c6cf6', '#f472b6'],
|
||||
pronouns: '',
|
||||
tag: null,
|
||||
links: [],
|
||||
widgets: { favoriteGame: null, rotation: [], wishlist: [], quote: null },
|
||||
};
|
||||
|
||||
// ─── Normalisation ────────────────────────────────────────────────────────────
|
||||
|
||||
const HEX = /^#[0-9a-fA-F]{6}$/;
|
||||
/** Domains whose images a profile may embed. Anything else is dropped, not
|
||||
* proxied — a profile is not a place to load arbitrary third-party pixels. */
|
||||
const COVER_HOSTS = ['cdn.cloudflare.steamstatic.com', 'cdn.akamai.steamstatic.com', 'shared.cloudflare.steamstatic.com', 'shared.akamai.steamstatic.com', 'steamcdn-a.akamaihd.net'];
|
||||
|
||||
/**
|
||||
* This package compiles without the DOM or Node type libraries, so the `URL`
|
||||
* global has no type here even though it exists at runtime everywhere this
|
||||
* code runs (browsers, Node ≥ 10). Reach it through globalThis with a minimal
|
||||
* structural type rather than pulling a whole lib into a types-only package.
|
||||
*/
|
||||
interface UrlLike { protocol: string; hostname: string; toString(): string }
|
||||
const UrlCtor = (globalThis as unknown as { URL: new (s: string) => UrlLike }).URL;
|
||||
function parseUrl(value: string): UrlLike | null {
|
||||
try { return new UrlCtor(value); } catch { return null; }
|
||||
}
|
||||
|
||||
function pick<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
|
||||
return typeof value === 'string' && (allowed as readonly string[]).includes(value) ? (value as T) : fallback;
|
||||
}
|
||||
function hex(value: unknown, fallback: string): string {
|
||||
return typeof value === 'string' && HEX.test(value) ? value.toLowerCase() : fallback;
|
||||
}
|
||||
/** Clamps to a range, falling back when the value is not a finite number. */
|
||||
function num(value: unknown, min: number, max: number, fallback: number): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
|
||||
return Math.min(max, Math.max(min, Math.round(value * 10) / 10));
|
||||
}
|
||||
|
||||
function str(value: unknown, max: number): string {
|
||||
return typeof value === 'string' ? value.trim().slice(0, max) : '';
|
||||
}
|
||||
function httpsUrl(value: unknown, max: number): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const v = value.trim();
|
||||
if (v.length === 0 || v.length > max) return null;
|
||||
const u = parseUrl(v);
|
||||
return u && u.protocol === 'https:' ? u.toString() : null;
|
||||
}
|
||||
function coverUrl(value: unknown): string | null {
|
||||
const u = httpsUrl(value, 300);
|
||||
if (!u) return null;
|
||||
const parsed = parseUrl(u);
|
||||
return parsed && COVER_HOSTS.includes(parsed.hostname) ? u : null;
|
||||
}
|
||||
/** First grapheme only — one emoji, not a sentence hidden in the tag. */
|
||||
function oneGrapheme(value: unknown): string {
|
||||
if (typeof value !== 'string') return '';
|
||||
const s = value.trim();
|
||||
if (!s) return '';
|
||||
const seg = (Intl as unknown as { Segmenter?: new (l: string, o: { granularity: string }) => { segment: (s: string) => Iterable<{ segment: string }> } }).Segmenter;
|
||||
if (seg) {
|
||||
for (const g of new seg('und', { granularity: 'grapheme' }).segment(s)) return g.segment;
|
||||
}
|
||||
return Array.from(s)[0] ?? '';
|
||||
}
|
||||
function game(value: unknown): GameEntry | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const v = value as Record<string, unknown>;
|
||||
const name = str(v.name, PROFILE_LIMITS.gameName);
|
||||
if (!name) return null;
|
||||
const appId = typeof v.appId === 'number' && Number.isInteger(v.appId) && v.appId > 0 ? v.appId : null;
|
||||
const tags = Array.isArray(v.tags)
|
||||
? v.tags.map((t) => str(t, PROFILE_LIMITS.gameTag)).filter(Boolean).slice(0, PROFILE_LIMITS.gameTags)
|
||||
: [];
|
||||
return { appId, name, cover: coverUrl(v.cover), note: str(v.note, PROFILE_LIMITS.gameNote), tags };
|
||||
}
|
||||
function games(value: unknown, max: number): GameEntry[] {
|
||||
return Array.isArray(value) ? value.map(game).filter((g): g is GameEntry => g !== null).slice(0, max) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerces any input into a valid ProfileStyle. Never throws: unknown ids fall
|
||||
* back to defaults, over-long strings are cut, bad colours are replaced, bad
|
||||
* URLs are dropped. The server stores exactly this output.
|
||||
*/
|
||||
export function normalizeProfileStyle(input: unknown): ProfileStyle {
|
||||
const d = DEFAULT_PROFILE_STYLE;
|
||||
const v = (input && typeof input === 'object' ? input : {}) as Record<string, unknown>;
|
||||
const name = (v.name && typeof v.name === 'object' ? v.name : {}) as Record<string, unknown>;
|
||||
const nameColors = Array.isArray(name.colors) ? name.colors : [];
|
||||
const theme = (v.theme && typeof v.theme === 'object' ? v.theme : null) as Record<string, unknown> | null;
|
||||
const themeColors = theme && Array.isArray(theme.colors) ? theme.colors : null;
|
||||
const npColors = Array.isArray(v.nameplateColors) ? v.nameplateColors : [];
|
||||
const banner = (v.banner && typeof v.banner === 'object' ? v.banner : {}) as Record<string, unknown>;
|
||||
const tag = (v.tag && typeof v.tag === 'object' ? v.tag : null) as Record<string, unknown> | null;
|
||||
const tagText = tag ? str(tag.text, PROFILE_LIMITS.tagMax).toUpperCase().replace(/[^A-Z0-9]/g, '') : '';
|
||||
const widgets = (v.widgets && typeof v.widgets === 'object' ? v.widgets : {}) as Record<string, unknown>;
|
||||
const quote = (widgets.quote && typeof widgets.quote === 'object' ? widgets.quote : null) as Record<string, unknown> | null;
|
||||
const quoteText = quote ? str(quote.text, PROFILE_LIMITS.quoteText) : '';
|
||||
|
||||
const links: ProfileLink[] = Array.isArray(v.links)
|
||||
? v.links.map((l) => {
|
||||
const o = (l && typeof l === 'object' ? l : {}) as Record<string, unknown>;
|
||||
const url = httpsUrl(o.url, PROFILE_LIMITS.linkUrl);
|
||||
if (!url) return null;
|
||||
const label = str(o.label, PROFILE_LIMITS.linkLabel) || (parseUrl(url)?.hostname ?? url).replace(/^www\./, '');
|
||||
return { label, url };
|
||||
}).filter((l): l is ProfileLink => l !== null).slice(0, PROFILE_LIMITS.links)
|
||||
: [];
|
||||
|
||||
return {
|
||||
name: {
|
||||
font: pick(name.font, NAME_FONTS, d.name.font),
|
||||
effect: pick(name.effect, NAME_EFFECTS, d.name.effect),
|
||||
colors: [hex(nameColors[0], d.name.colors[0]), hex(nameColors[1], d.name.colors[1])],
|
||||
},
|
||||
theme: themeColors ? { colors: [hex(themeColors[0], '#7c6cf6'), hex(themeColors[1], '#0b0b10')] } : null,
|
||||
banner: {
|
||||
x: num(banner.x, 0, 100, d.banner.x),
|
||||
y: num(banner.y, 0, 100, d.banner.y),
|
||||
zoom: num(banner.zoom, 100, 300, d.banner.zoom),
|
||||
},
|
||||
decoration: pick(v.decoration, AVATAR_DECORATIONS, d.decoration),
|
||||
effect: pick(v.effect, PROFILE_EFFECTS, d.effect),
|
||||
nameplate: pick(v.nameplate, NAMEPLATES, d.nameplate),
|
||||
nameplateColors: [hex(npColors[0], d.nameplateColors[0]), hex(npColors[1], d.nameplateColors[1])],
|
||||
pronouns: str(v.pronouns, PROFILE_LIMITS.pronouns),
|
||||
tag: tagText.length >= PROFILE_LIMITS.tagMin
|
||||
? { text: tagText, emoji: oneGrapheme(tag?.emoji), color: hex(tag?.color, '#7c6cf6') }
|
||||
: null,
|
||||
links,
|
||||
widgets: {
|
||||
favoriteGame: game(widgets.favoriteGame),
|
||||
rotation: games(widgets.rotation, PROFILE_LIMITS.rotation),
|
||||
wishlist: games(widgets.wishlist, PROFILE_LIMITS.wishlist),
|
||||
quote: quoteText ? { text: quoteText, author: str(quote?.author, PROFILE_LIMITS.quoteAuthor) } : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether a style differs from the defaults — used to skip rendering work. */
|
||||
export function isDefaultProfileStyle(s: ProfileStyle | null | undefined): boolean {
|
||||
return !s || JSON.stringify(s) === JSON.stringify(DEFAULT_PROFILE_STYLE);
|
||||
}
|
||||
|
||||
/** Steam's poster-format cover for an app id. */
|
||||
export function steamCoverUrl(appId: number): string {
|
||||
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appId}/library_600x900.jpg`;
|
||||
}
|
||||
@@ -29,6 +29,8 @@ export interface User {
|
||||
showActivity?: boolean;
|
||||
/** Self-view only: this federated account's home instance was reset/lost — it now operates as a sovereign local account (detach spec). */
|
||||
federationHomeOrphaned?: boolean;
|
||||
/** Name style, decorations, effects, tag, links, widgets. See shared/profile.ts. Null = defaults. */
|
||||
profileStyle?: import('./profile').ProfileStyle | null;
|
||||
}
|
||||
|
||||
export interface ReplicatedInstance {
|
||||
@@ -212,6 +214,11 @@ export interface Message {
|
||||
type?: 'user' | 'system';
|
||||
editedAt: number | null;
|
||||
createdAt: number;
|
||||
/** Epoch millis when pinned; null when not pinned. */
|
||||
pinnedAt?: number | null;
|
||||
pinnedBy?: string | null;
|
||||
/** Figurinha enviada como mensagem; o conteúdo fica vazio nesse caso. */
|
||||
stickerId?: string | null;
|
||||
}
|
||||
|
||||
export interface MessageWithUser extends Message {
|
||||
@@ -441,6 +448,7 @@ export type ServerEvent =
|
||||
| { type: 'presence_update'; userId: string; status: string; activities?: Activity[] }
|
||||
| { type: 'voice_state_update'; channelId: string; userId: string; action: 'join' | 'leave'; startedAt?: number }
|
||||
| { type: 'soundboard_played'; soundId: string; userId: string; name: string; filename: string }
|
||||
| { type: 'message_pinned'; channelId: string; messageId: string; pinned: boolean }
|
||||
| { type: 'member_joined'; spaceId: string; member: MemberWithUser }
|
||||
| { type: 'member_left'; spaceId: string; userId: string }
|
||||
| { type: 'dm_message_created'; message: DmMessageWithUser }
|
||||
@@ -585,6 +593,8 @@ export interface UpdateUserRequest {
|
||||
profileUpdatedAt?: number;
|
||||
discoverable?: boolean;
|
||||
showActivity?: boolean;
|
||||
/** Normalised server-side; pass null to reset to defaults. */
|
||||
profileStyle?: import('./profile').ProfileStyle | null;
|
||||
}
|
||||
|
||||
export interface UpdateMemberRequest {
|
||||
@@ -595,6 +605,8 @@ export interface CreateMessageRequest {
|
||||
content: string;
|
||||
attachments?: string[];
|
||||
replyToId?: string;
|
||||
/** Envio de figurinha; nesse caso `content` fica vazio. */
|
||||
stickerId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateMessageRequest {
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<meta name="description" content="The self-hosted Discord and TeamSpeak alternative. HD voice, video, and screen share — open source and free (AGPL-3.0)." />
|
||||
<meta name="description" content="Resenhacord — o lugar da resenha. Voz, vídeo e tela em HD, num servidor só nosso. Fork do Backspace, código aberto (AGPL-3.0)." />
|
||||
<meta name="theme-color" content="#0b0b10" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Backspace" />
|
||||
<meta name="apple-mobile-web-app-title" content="Resenhacord" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16.png" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||
<title>Backspace</title>
|
||||
<title>Resenhacord</title>
|
||||
</head>
|
||||
<body class="bg-surface-base text-txt-primary">
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -91,3 +91,9 @@ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
---
|
||||
Display fonts for profile name styles (latin subsets, Google Fonts builds), all
|
||||
licensed under the SIL Open Font License 1.1 above:
|
||||
Bangers, Cinzel, Fredoka, Orbitron, Pacifico, Permanent Marker, Playfair Display,
|
||||
Press Start 2P, Rubik Mono One, Space Mono.
|
||||
|
||||
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 955 B After Width: | Height: | Size: 712 B |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 368 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 10 KiB |
@@ -1,16 +1,33 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Ebene_2" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 123.08 100.14">
|
||||
<!--
|
||||
Resenhacord mark: two speech bubbles in conversation, the front one talking.
|
||||
Square viewBox so every raster target (tray, favicon, PWA) scales without
|
||||
letterboxing. The gap between the bubbles is cut with a mask rather than
|
||||
painted, so the mark stays legible on any background and the macOS template
|
||||
(alpha-only) variant keeps the same silhouette.
|
||||
-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #fff;
|
||||
}
|
||||
</style>
|
||||
<linearGradient id="back" x1="10" y1="12" x2="74" y2="74" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#c4b5fd"/>
|
||||
<stop offset="1" stop-color="#7c6cf6"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="front" x1="42" y1="42" x2="94" y2="94" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#fda4af"/>
|
||||
<stop offset="1" stop-color="#f472b6"/>
|
||||
</linearGradient>
|
||||
<path id="frontBubble" d="M56 42 H80 A14 14 0 0 1 94 56 V68 A14 14 0 0 1 80 82 H76 L88 94 L68 82 H56 A14 14 0 0 1 42 68 V56 A14 14 0 0 1 56 42 Z"/>
|
||||
<mask id="gap">
|
||||
<rect width="100" height="100" fill="#fff"/>
|
||||
<use href="#frontBubble" fill="#000" stroke="#000" stroke-width="7" stroke-linejoin="round"/>
|
||||
</mask>
|
||||
</defs>
|
||||
<g id="Ebene_1-2" data-name="Ebene 1">
|
||||
<g>
|
||||
<path class="cls-1" d="M91.64,53.93c9.67-3.15,14.27-9.44,14.27-20.12,0-13.48-9.33-21.23-25.52-21.23h-16.13c-7.98,0-11.35,3.48-11.35,12.14v53.82h0c5.77,0,10.46-4.68,10.46-10.46v-7.44c-.06-.33-.1-.68-.09-1.05,0-3.35.07-6.69.12-10.04h-.03v-24.05c0-2.24,1.12-3.6,3.25-3.6h12.99c10.23,0,15.51,4.5,15.51,13.26s-5.62,14.38-15.29,14.38h-6.04c-.04,3.08-.1,6.14-.11,9.22h6.5c11.46,0,17.64,5.51,17.64,16.08s-8.01,16.22-19.36,16.22l-4.36.38h0l-10.57.41c-6.01,0-6.3,8.01-.3,8.25.34.01.68.02,1.03.02h17.37c17.32,0,27.31-8.99,27.31-24.28,0-12.14-5.84-19.56-17.3-21.92Z"/>
|
||||
<path class="cls-1" d="M93.21,98.04c-1.58.67-3.48,1.14-5.16,1.36-7.86,1.04-15.77.76-23.61.52-3.06-.04-7.8-.42-10.92-.56-2.12-.13-4.23-.56-6.23-1.28-4.01-1.34-7.48-4.13-10.26-7.27-3.66-3.85-11.08-11.56-14.75-15.4-2.49-2.54-4.81-5.23-7.56-7.52-4.08-3.48-7.59-7.49-11.21-11.42-3.78-3.58-4.71-9.08-1.77-13.49.6-.98,1.66-1.88,2.47-2.69,6.89-6.67,19.79-19.17,26.81-25.96,0,0,7.66-7.42,7.66-7.42,1.47-1.45,3.08-2.88,4.89-3.91C46.83,1.04,50.61,0,54.4,0c.89-.03,5.73.13,6.73.12,0,0,10.66.21,10.66.21,7.02.15,25.23.49,32.04.65,8.24.22,15.88,5.97,18.3,13.83,1.26,3.64.92,7.57.85,11.31,0,0-.85,42.64-.85,42.64-.31,13.92,1.41,31-17.86,30.33-.78,0-1.57-.04-2.33-.23-.11-.03-.22.01-.33-.26.11-.27.22-.22.33-.26,3.35-.48,6.76-.1,9.95-1.53,6.81-2.78,8.04-10.78,7.88-17.39-.14-6.81-.88-45.63-1.05-53.3-.12-3.34.17-6.68-1.03-9.81-1.03-2.88-3-5.41-5.51-7.15-2.48-1.76-5.49-2.67-8.48-2.67,0,0-10.6.19-10.6.19-9.89.2-27.61.55-37.32.74-3.9-.11-7.81,1.33-10.51,4.18-.22.33-7.8,8.1-8.26,8.65-4.86,5.11-17.37,18.21-22.08,23.15-.73.81-4.11,4.27-4.82,5.05,0,0,0,.01,0,.01-.01,0,.01.02,0,.01.02.02.08.07.09.03.02-.02,0-.01.02-.03,0,0,.81.83.81.83,3.71,3.84,7.52,7.57,10.75,11.84,3.79,4.86,10.17,11.13,14.34,15.78,0,0,7.27,7.8,7.27,7.8,1.85,2.11,4.03,4.24,6.79,5.09,2.44.99,6.67.88,9.28,1.08,3.39.17,7.13.31,10.53.37,5.86.03,11.79.22,17.4-1.24,0,0,.9-.31.9-.31.35-.2.82-.44,1.2-.58,6.01-2.4,9.65,6.26,3.7,8.87h0Z"/>
|
||||
</g>
|
||||
<path mask="url(#gap)" fill="url(#back)"
|
||||
d="M26 12 H58 A16 16 0 0 1 74 28 V44 A16 16 0 0 1 58 60 H34 L16 74 L20 60 H26 A16 16 0 0 1 10 44 V28 A16 16 0 0 1 26 12 Z"/>
|
||||
<use href="#frontBubble" fill="url(#front)"/>
|
||||
<g fill="#fff">
|
||||
<circle cx="58" cy="62" r="3.6"/>
|
||||
<circle cx="68" cy="62" r="3.6"/>
|
||||
<circle cx="78" cy="62" r="3.6"/>
|
||||
</g>
|
||||
</svg>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 24 KiB |
@@ -75,6 +75,17 @@ import type {
|
||||
} from '@backspace/shared';
|
||||
import type { AuditEvent } from '@backspace/shared/src/audit.js';
|
||||
|
||||
export interface SpaceEmoji {
|
||||
id: string;
|
||||
spaceId: string;
|
||||
name: string;
|
||||
filename: string;
|
||||
uploaderId: string | null;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export type SpaceSticker = SpaceEmoji;
|
||||
|
||||
export interface SoundboardSound {
|
||||
id: string;
|
||||
spaceId: string;
|
||||
@@ -321,6 +332,25 @@ export class BackspaceApiClient {
|
||||
space: (spaceId: string, days: number) => Promise<SpaceStats>;
|
||||
};
|
||||
|
||||
readonly expressions: {
|
||||
emojis: (spaceId: string) => Promise<{ emojis: SpaceEmoji[] }>;
|
||||
addEmoji: (spaceId: string, name: string, filename: string) => Promise<SpaceEmoji>;
|
||||
removeEmoji: (id: string) => Promise<void>;
|
||||
stickers: (spaceId: string) => Promise<{ stickers: SpaceSticker[] }>;
|
||||
addSticker: (spaceId: string, name: string, filename: string) => Promise<SpaceSticker>;
|
||||
removeSticker: (id: string) => Promise<void>;
|
||||
};
|
||||
|
||||
readonly games: {
|
||||
search: (q: string) => Promise<{ hits: { appId: number; name: string; cover: string; header: string }[] }>;
|
||||
};
|
||||
|
||||
readonly pins: {
|
||||
list: (channelId: string) => Promise<{ messages: MessageWithUser[] }>;
|
||||
pin: (messageId: string) => Promise<{ success: boolean }>;
|
||||
unpin: (messageId: string) => Promise<{ success: boolean }>;
|
||||
};
|
||||
|
||||
readonly audit: {
|
||||
log: (spaceId: string, before?: string) => Promise<{ events: AuditEvent[]; hasMore: boolean }>;
|
||||
};
|
||||
@@ -751,6 +781,25 @@ export class BackspaceApiClient {
|
||||
request<SpaceStats>('GET', `/spaces/${spaceId}/stats?days=${days}`),
|
||||
};
|
||||
|
||||
this.expressions = {
|
||||
emojis: (spaceId) => request<{ emojis: SpaceEmoji[] }>('GET', `/spaces/${spaceId}/emojis`),
|
||||
addEmoji: (spaceId, name, filename) => request<SpaceEmoji>('POST', `/spaces/${spaceId}/emojis`, { name, filename }),
|
||||
removeEmoji: (id) => request<void>('DELETE', `/emojis/${id}`),
|
||||
stickers: (spaceId) => request<{ stickers: SpaceSticker[] }>('GET', `/spaces/${spaceId}/stickers`),
|
||||
addSticker: (spaceId, name, filename) => request<SpaceSticker>('POST', `/spaces/${spaceId}/stickers`, { name, filename }),
|
||||
removeSticker: (id) => request<void>('DELETE', `/stickers/${id}`),
|
||||
};
|
||||
|
||||
this.games = {
|
||||
search: (q: string) => request<{ hits: { appId: number; name: string; cover: string; header: string }[] }>('GET', `/games/search?q=${encodeURIComponent(q)}`),
|
||||
};
|
||||
|
||||
this.pins = {
|
||||
list: (channelId: string) => request<{ messages: MessageWithUser[] }>('GET', `/channels/${channelId}/pins`),
|
||||
pin: (messageId: string) => request<{ success: boolean }>('PUT', `/messages/${messageId}/pin`),
|
||||
unpin: (messageId: string) => request<{ success: boolean }>('DELETE', `/messages/${messageId}/pin`),
|
||||
};
|
||||
|
||||
this.audit = {
|
||||
log: (spaceId: string, before?: string) => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
@@ -7,10 +7,12 @@ import { api, createApiClient } from '../api/client';
|
||||
import { parseInviteInput } from '../utils/inviteParser';
|
||||
import { Avatar } from './ui/Avatar';
|
||||
import type { InvitePreview } from '@backspace/shared';
|
||||
import { useT } from '../i18n';
|
||||
|
||||
type JoinPhase = 'preview' | 'connect' | 'fallback' | 'other-instance' | 'already-member';
|
||||
|
||||
export function JoinPage() {
|
||||
const t = useT();
|
||||
const { inviteCode: rawInviteCode } = useParams<{ inviteCode: string }>();
|
||||
const navigate = useNavigate();
|
||||
const token = useAuthStore((s) => s.token);
|
||||
@@ -59,13 +61,13 @@ export function JoinPage() {
|
||||
// Fetch preview on mount
|
||||
useEffect(() => {
|
||||
if (!rawInviteCode) {
|
||||
setPreviewError('No invite code provided');
|
||||
setPreviewError(t('join.page.noCode'));
|
||||
setIsLoadingPreview(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!parsed) {
|
||||
setPreviewError('Invalid invite code');
|
||||
setPreviewError(t('join.page.invalidCode'));
|
||||
setIsLoadingPreview(false);
|
||||
return;
|
||||
}
|
||||
@@ -84,7 +86,7 @@ export function JoinPage() {
|
||||
const data = await client.spaces.invitePreview(parsed.code);
|
||||
setPreview(data);
|
||||
} catch (err) {
|
||||
setPreviewError(err instanceof Error ? err.message : 'Failed to load invite');
|
||||
setPreviewError(err instanceof Error ? err.message : t('join.page.loadFailed'));
|
||||
} finally {
|
||||
setIsLoadingPreview(false);
|
||||
}
|
||||
@@ -106,7 +108,7 @@ export function JoinPage() {
|
||||
setPhase('connect');
|
||||
setError('');
|
||||
} else {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to join space';
|
||||
const msg = err instanceof Error ? err.message : t('join.page.joinFailed');
|
||||
if (msg.toLowerCase().includes('already a member')) {
|
||||
setPhase('already-member');
|
||||
setError('');
|
||||
@@ -136,7 +138,7 @@ export function JoinPage() {
|
||||
setFallbackPassword('');
|
||||
setError('');
|
||||
} else {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to connect';
|
||||
const msg = err instanceof Error ? err.message : t('join.page.connectFailed');
|
||||
if (msg.toLowerCase().includes('already a member')) {
|
||||
setPhase('already-member');
|
||||
setError('');
|
||||
@@ -160,7 +162,7 @@ export function JoinPage() {
|
||||
const space = await joinByCode(parsed.code, parsed.origin);
|
||||
navigate(`/channels/${space.id}`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to log in';
|
||||
const msg = err instanceof Error ? err.message : t('join.page.loginFailed');
|
||||
if (msg.toLowerCase().includes('already a member')) {
|
||||
setPhase('already-member');
|
||||
setError('');
|
||||
@@ -204,7 +206,7 @@ export function JoinPage() {
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<p className="text-txt-tertiary">Loading invite...</p>
|
||||
<p className="text-txt-tertiary">{t('join.page.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -221,16 +223,16 @@ export function JoinPage() {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-txt-primary mb-2">Invalid Invite</h1>
|
||||
<h1 className="text-xl font-bold text-txt-primary mb-2">{t('join.page.invalid.title')}</h1>
|
||||
<p className="text-txt-secondary text-sm mb-6">
|
||||
{previewError || 'This invite link is invalid or has expired.'}
|
||||
{previewError || t('join.page.invalid.body')}
|
||||
</p>
|
||||
{token ? (
|
||||
<button
|
||||
onClick={() => navigate('/channels/@me')}
|
||||
className="px-6 py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors"
|
||||
>
|
||||
Back to Backspace
|
||||
{t('join.page.back')}
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
@@ -260,7 +262,7 @@ export function JoinPage() {
|
||||
avatarColor={preview.avatarColor}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-txt-tertiary uppercase tracking-wide mb-1">You've been invited to join</p>
|
||||
<p className="text-xs text-txt-tertiary uppercase tracking-wide mb-1">{t('join.page.invitedTo')}</p>
|
||||
<h1 className="text-2xl font-bold text-txt-primary">{preview.spaceName}</h1>
|
||||
{preview.description && (
|
||||
<p className="text-txt-secondary text-sm mt-2">{preview.description}</p>
|
||||
@@ -303,7 +305,7 @@ export function JoinPage() {
|
||||
disabled={isJoining}
|
||||
className="w-full py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isJoining ? 'Joining...' : `Join as ${user.displayName || user.username}`}
|
||||
{isJoining ? t('join.joining') : `Join as ${user.displayName || user.username}`}
|
||||
</button>
|
||||
<p className="text-center text-xs text-txt-tertiary">
|
||||
Not you?{' '}
|
||||
@@ -378,7 +380,7 @@ export function JoinPage() {
|
||||
type="text"
|
||||
value={otherDomain}
|
||||
onChange={(e) => setOtherDomain(e.target.value)}
|
||||
placeholder="e.g. my-instance.com"
|
||||
placeholder={t('join.page.instance.placeholder')}
|
||||
className="input-standard flex-1 py-2.5"
|
||||
autoFocus
|
||||
/>
|
||||
@@ -431,7 +433,7 @@ export function JoinPage() {
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Your account password"
|
||||
placeholder={t('join.connect.password.placeholder')}
|
||||
className="input-standard w-full py-2.5"
|
||||
disabled={isJoining}
|
||||
autoFocus
|
||||
@@ -454,7 +456,7 @@ export function JoinPage() {
|
||||
disabled={isJoining || !password}
|
||||
className="flex-1 py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isJoining ? 'Connecting...' : 'Connect & Join'}
|
||||
{isJoining ? t('join.connect.connecting') : t('join.connect.submit')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -473,24 +475,24 @@ export function JoinPage() {
|
||||
</div>
|
||||
<div className="mb-4 space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-1.5">Username</label>
|
||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-1.5">{t('join.fallback.username')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={fallbackUsername}
|
||||
onChange={(e) => setFallbackUsername(e.target.value)}
|
||||
placeholder="Your username on this instance"
|
||||
placeholder={t('join.fallback.username.placeholder')}
|
||||
className="input-standard w-full py-2.5"
|
||||
disabled={isJoining}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-1.5">Password for this instance</label>
|
||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-1.5">{t('join.fallback.password')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={fallbackPassword}
|
||||
onChange={(e) => setFallbackPassword(e.target.value)}
|
||||
placeholder="Password on the remote instance"
|
||||
placeholder={t('join.fallback.password.placeholder')}
|
||||
className="input-standard w-full py-2.5"
|
||||
disabled={isJoining}
|
||||
autoFocus
|
||||
@@ -511,7 +513,7 @@ export function JoinPage() {
|
||||
disabled={isJoining || !fallbackUsername || !fallbackPassword}
|
||||
className="flex-1 py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isJoining ? 'Logging in...' : 'Login & Join'}
|
||||
{isJoining ? t('join.fallback.loggingIn') : t('join.fallback.submit')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -522,6 +524,7 @@ export function JoinPage() {
|
||||
}
|
||||
|
||||
function AlreadyMemberCard({ spaceName, spaceId, navigate }: { spaceName: string; spaceId: string; navigate: (path: string) => void }) {
|
||||
const t = useT();
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => navigate(`/channels/${spaceId}`), 2000);
|
||||
return () => clearTimeout(timer);
|
||||
@@ -534,13 +537,13 @@ function AlreadyMemberCard({ spaceName, spaceId, navigate }: { spaceName: string
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-txt-primary font-medium mb-1">You're already in {spaceName}!</p>
|
||||
<p className="text-txt-tertiary text-xs mb-4">Redirecting you now...</p>
|
||||
<p className="text-txt-primary font-medium mb-1">{t('join.page.alreadyMember', { space: spaceName })}</p>
|
||||
<p className="text-txt-tertiary text-xs mb-4">{t('join.page.redirecting')}</p>
|
||||
<button
|
||||
onClick={() => navigate(`/channels/${spaceId}`)}
|
||||
className="px-6 py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors"
|
||||
>
|
||||
Go to {spaceName}
|
||||
{t('join.page.goTo', { space: spaceName })}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useInAppNotificationStore } from '../stores/inAppNotificationStore';
|
||||
import { AudioManager } from '../audio/AudioManager';
|
||||
import { getSfxVolume } from '../utils/sfx';
|
||||
import { translate, useLocaleStore } from '../i18n';
|
||||
import { useChatStore } from '../stores/chatStore';
|
||||
import { useVoiceStore } from '../stores/voiceStore';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
@@ -51,19 +55,40 @@ export function NotificationController() {
|
||||
|
||||
const unsubscribeChat = useChatStore.subscribe((state, prevState) => {
|
||||
if (isInitialMount.current) return;
|
||||
if (windowFocused.current) return;
|
||||
// Antes daqui só havia aviso com a janela fora de foco. Agora a janela em
|
||||
// foco também avisa, mas dentro do app e apenas para outro canal — avisar
|
||||
// sobre a conversa que a pessoa está lendo seria ruído.
|
||||
const focused = windowFocused.current;
|
||||
const currentChannel = state.currentChannelId;
|
||||
|
||||
if (state.realtimeMessageEvents.length > prevState.realtimeMessageEvents.length) {
|
||||
const newEvents = state.realtimeMessageEvents.slice(prevState.realtimeMessageEvents.length);
|
||||
for (const { message } of newEvents) {
|
||||
if (message.userId !== currentUser?.id) {
|
||||
// System notices (pins, membership) are not from anyone — no toast.
|
||||
if (message.type !== 'system' && message.userId !== currentUser?.id) {
|
||||
if (focused && message.channelId === currentChannel) break;
|
||||
|
||||
const displayName = message.user?.displayName || message.user?.username || 'Someone';
|
||||
const body = message.content
|
||||
? message.content.replace(/[*_~`>#\-\[\]]/g, '').slice(0, 100)
|
||||
: 'Sent an attachment';
|
||||
sendNotification(displayName, body, {
|
||||
: translate(useLocaleStore.getState().locale, 'notify.attachment');
|
||||
|
||||
useInAppNotificationStore.getState().push({
|
||||
title: displayName,
|
||||
body,
|
||||
avatar: message.user?.avatar ?? null,
|
||||
userId: message.user?.id,
|
||||
channelId: message.channelId,
|
||||
});
|
||||
// Efeito próprio, no lugar do som do sistema. O balão nativo agora
|
||||
// é silencioso, então este é o único som que toca.
|
||||
void AudioManager.getInstance().playSound('notification', { volume: getSfxVolume() });
|
||||
|
||||
// O balão do sistema só faz sentido quando a janela não está à
|
||||
// vista: com ela em foco, o aviso dentro do app já cumpre o papel.
|
||||
if (!focused) {
|
||||
sendNotification(displayName, body, { channelId: message.channelId });
|
||||
}
|
||||
break; // one notification per batch
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import { useAuthStore } from '../../stores/authStore';
|
||||
import { api, RateLimitError } from '../../api/client';
|
||||
import type { InstanceInfoResponse } from '@backspace/shared';
|
||||
import { SourceCodeLink } from '../ui/SourceCodeLink';
|
||||
import { useT } from '../../i18n';
|
||||
|
||||
export function LoginPage() {
|
||||
const t = useT();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
@@ -46,11 +48,11 @@ export function LoginPage() {
|
||||
setError('');
|
||||
|
||||
if (!username.trim()) {
|
||||
setError('Username is required');
|
||||
setError(t('auth.username.required'));
|
||||
return;
|
||||
}
|
||||
if (!password) {
|
||||
setError('Password is required');
|
||||
setError(t('auth.password.required'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -66,7 +68,7 @@ export function LoginPage() {
|
||||
setRetryAfter(err.retryAfter);
|
||||
setError('');
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Login failed');
|
||||
setError(err instanceof Error ? err.message : t('auth.login.failed'));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -78,15 +80,15 @@ export function LoginPage() {
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(124,108,246,0.06)_0%,transparent_50%)]" />
|
||||
<div className="w-full max-w-[480px] bg-surface-elevated rounded-md p-8 shadow-elevation-high relative z-10">
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-txt-primary">Welcome back!</h1>
|
||||
<p className="text-txt-tertiary mt-1">We're so excited to see you again!</p>
|
||||
<h1 className="text-2xl font-bold text-txt-primary">{t('auth.login.title')}</h1>
|
||||
<p className="text-txt-tertiary mt-1">{t('auth.login.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{retryAfter > 0 && (
|
||||
<div className="mb-4 p-3 bg-accent-amber/10 border border-accent-amber/30 rounded text-sm">
|
||||
<p className="font-medium text-accent-amber">Too many login attempts</p>
|
||||
<p className="text-txt-secondary mt-0.5">Try again in {retryAfter}s</p>
|
||||
<p className="font-medium text-accent-amber">{t('auth.login.rateLimited')}</p>
|
||||
<p className="text-txt-secondary mt-0.5">{t('auth.retryIn', { seconds: retryAfter })}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -98,7 +100,7 @@ export function LoginPage() {
|
||||
|
||||
<div className="mb-5">
|
||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||
Username <span className="text-txt-danger">*</span>
|
||||
{t('auth.username')} <span className="text-txt-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -112,7 +114,7 @@ export function LoginPage() {
|
||||
|
||||
<div className="mb-5">
|
||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||
Password <span className="text-txt-danger">*</span>
|
||||
{t('auth.password')} <span className="text-txt-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
@@ -129,16 +131,16 @@ export function LoginPage() {
|
||||
className="w-full py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{retryAfter > 0
|
||||
? `Try again in ${retryAfter}s`
|
||||
? t('auth.retryIn', { seconds: retryAfter })
|
||||
: isLoading
|
||||
? 'Logging in...'
|
||||
: 'Log In'}
|
||||
? t('auth.login.loggingIn')
|
||||
: t('auth.login.submit')}
|
||||
</button>
|
||||
|
||||
<p className="mt-3 text-sm text-txt-tertiary">
|
||||
Need an account?{' '}
|
||||
{t('auth.needAccount')}{' '}
|
||||
<Link to={`/register${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ''}`} className="text-accent-primary hover:underline">
|
||||
Register
|
||||
{t('auth.register')}
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { api, RateLimitError } from '../../api/client';
|
||||
import { useTransferStore } from '../../stores/transferStore';
|
||||
import { waitForTransferAttachment } from '../../utils/waitForTransfer';
|
||||
import { SourceCodeLink } from '../ui/SourceCodeLink';
|
||||
import { useT } from '../../i18n';
|
||||
|
||||
// Single-source regex for extracting a bare invite token from a pasted full URL.
|
||||
// Token format: 22 chars base64url ([A-Za-z0-9_-]).
|
||||
@@ -18,6 +19,7 @@ const INVITE_URL_REGEX = /[?&]invite=([A-Za-z0-9_-]{22})/;
|
||||
type UsernameStatus = 'idle' | 'checking' | 'available' | 'taken' | 'invalid';
|
||||
|
||||
export function RegisterPage() {
|
||||
const t = useT();
|
||||
// Step state
|
||||
const [step, setStep] = useState<1 | 2>(1);
|
||||
const [direction, setDirection] = useState<'forward' | 'back'>('forward');
|
||||
@@ -187,18 +189,18 @@ export function RegisterPage() {
|
||||
|
||||
if (trimmed.length < 3 || trimmed.length > 32) {
|
||||
setUsernameStatus(trimmed.length > 0 ? 'invalid' : 'idle');
|
||||
setUsernameStatusMessage(trimmed.length > 0 ? 'Username must be between 3 and 32 characters' : '');
|
||||
setUsernameStatusMessage(trimmed.length > 0 ? t('auth.register.username.length') : '');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9_]+$/.test(trimmed)) {
|
||||
setUsernameStatus('invalid');
|
||||
setUsernameStatusMessage('Username can only contain lowercase letters, numbers, and underscores');
|
||||
setUsernameStatusMessage(t('auth.register.username.charset'));
|
||||
return;
|
||||
}
|
||||
|
||||
setUsernameStatus('checking');
|
||||
setUsernameStatusMessage('Checking availability...');
|
||||
setUsernameStatusMessage(t('auth.register.username.checking'));
|
||||
|
||||
usernameCheckTimerRef.current = setTimeout(async () => {
|
||||
const controller = new AbortController();
|
||||
@@ -213,10 +215,10 @@ export function RegisterPage() {
|
||||
setUsernameStatusMessage(result.reason);
|
||||
} else if (result.available) {
|
||||
setUsernameStatus('available');
|
||||
setUsernameStatusMessage('Username is available');
|
||||
setUsernameStatusMessage(t('auth.register.username.available'));
|
||||
} else {
|
||||
setUsernameStatus('taken');
|
||||
setUsernameStatusMessage('Username is already taken');
|
||||
setUsernameStatusMessage(t('auth.register.username.taken'));
|
||||
}
|
||||
} catch {
|
||||
if (controller.signal.aborted) return;
|
||||
@@ -265,30 +267,30 @@ export function RegisterPage() {
|
||||
|
||||
const trimmed = username.trim();
|
||||
if (!trimmed) {
|
||||
setError('Username is required');
|
||||
setError(t('auth.username.required'));
|
||||
return;
|
||||
}
|
||||
if (trimmed.length < 3 || trimmed.length > 32) {
|
||||
setError('Username must be between 3 and 32 characters');
|
||||
setError(t('auth.register.username.length'));
|
||||
return;
|
||||
}
|
||||
if (!/^[a-z0-9_]+$/.test(trimmed)) {
|
||||
setError('Username can only contain lowercase letters, numbers, and underscores');
|
||||
setError(t('auth.register.username.charset'));
|
||||
return;
|
||||
}
|
||||
if (usernameStatus === 'taken' || usernameStatus === 'invalid') {
|
||||
return;
|
||||
}
|
||||
if (!password) {
|
||||
setError('Password is required');
|
||||
setError(t('auth.password.required'));
|
||||
return;
|
||||
}
|
||||
if (password.length < 6) {
|
||||
setError('Password must be at least 6 characters');
|
||||
setError(t('auth.register.password.length'));
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
setError(t('auth.register.password.mismatch'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -379,7 +381,7 @@ export function RegisterPage() {
|
||||
setRetryAfter(err.retryAfter);
|
||||
setError('');
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Registration failed');
|
||||
setError(err instanceof Error ? err.message : t('auth.register.failed'));
|
||||
}
|
||||
setIsRegistering(false);
|
||||
}
|
||||
@@ -418,7 +420,7 @@ export function RegisterPage() {
|
||||
{step === 1 ? (
|
||||
<div key="step1" className={`w-full${direction === 'back' ? ' animate-step-back' : ''}`}>
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-txt-primary">Create an account</h1>
|
||||
<h1 className="text-2xl font-bold text-txt-primary">{t('auth.register.title')}</h1>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleContinue}>
|
||||
@@ -439,7 +441,7 @@ export function RegisterPage() {
|
||||
type="text"
|
||||
value={manualInviteToken}
|
||||
onChange={(e) => setManualInviteToken(e.target.value)}
|
||||
placeholder="Invite code or link"
|
||||
placeholder={t('auth.register.invite')}
|
||||
// text-base on mobile prevents iOS Safari from auto-zooming
|
||||
// when the field is focused (any <input> with font-size <16px triggers zoom).
|
||||
className="input-standard w-full px-3 py-2 text-base md:text-sm"
|
||||
@@ -447,7 +449,7 @@ export function RegisterPage() {
|
||||
autoComplete="off"
|
||||
/>
|
||||
{inviteChecking && (
|
||||
<div className="text-xs text-txt-tertiary">Checking...</div>
|
||||
<div className="text-xs text-txt-tertiary">{t('auth.register.invite.checking')}</div>
|
||||
)}
|
||||
{!inviteChecking && inviteCheck?.valid === true && (
|
||||
<div className="text-xs text-status-online flex items-center gap-1">
|
||||
@@ -459,9 +461,9 @@ export function RegisterPage() {
|
||||
)}
|
||||
{!inviteChecking && inviteCheck?.valid === false && (
|
||||
<div className="text-xs text-txt-danger">
|
||||
{inviteCheck.reason === 'expired' && 'This invite link has expired. Ask the admin for a new one.'}
|
||||
{inviteCheck.reason === 'exhausted' && 'This invite has reached its usage limit. Ask the admin to extend it.'}
|
||||
{inviteCheck.reason === 'invalid' && 'Invalid invite code.'}
|
||||
{inviteCheck.reason === 'expired' && t('auth.register.invite.expired')}
|
||||
{inviteCheck.reason === 'exhausted' && t('auth.register.invite.exhausted')}
|
||||
{inviteCheck.reason === 'invalid' && t('auth.register.invite.invalid')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -480,7 +482,7 @@ export function RegisterPage() {
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span>Validating invite...</span>
|
||||
<span>{t('auth.register.invite.validating')}</span>
|
||||
</>
|
||||
) : inviteCheck?.valid === true ? (
|
||||
<>
|
||||
@@ -494,17 +496,17 @@ export function RegisterPage() {
|
||||
<svg className="w-3 h-3 flex-shrink-0 mt-0.5 md:mt-0" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>Invalid invite link — please request a new one</span>
|
||||
<span>{t('auth.register.invite.invalidLink')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>Validating invite...</>
|
||||
<>{t('auth.register.invite.validating')}</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-5">
|
||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||
Username <span className="text-txt-danger">*</span>
|
||||
{t('auth.username')} <span className="text-txt-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -544,7 +546,7 @@ export function RegisterPage() {
|
||||
|
||||
<div className="mb-5">
|
||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||
Password <span className="text-txt-danger">*</span>
|
||||
{t('auth.password')} <span className="text-txt-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
@@ -557,7 +559,7 @@ export function RegisterPage() {
|
||||
|
||||
<div className="mb-5">
|
||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||
Confirm Password <span className="text-txt-danger">*</span>
|
||||
{t('auth.register.confirmPassword')} <span className="text-txt-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
@@ -575,7 +577,7 @@ export function RegisterPage() {
|
||||
// tighter desktop look from before.
|
||||
className="w-full py-3 md:py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Continue
|
||||
{t('auth.register.continue')}
|
||||
</button>
|
||||
|
||||
{/* Helper text when invite is required but not yet entered */}
|
||||
@@ -586,9 +588,9 @@ export function RegisterPage() {
|
||||
)}
|
||||
|
||||
<p className="mt-3 text-sm text-txt-tertiary">
|
||||
Already have an account?{' '}
|
||||
{t('auth.haveAccount')}{' '}
|
||||
<Link to={`/login${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ''}`} className="text-accent-primary hover:underline">
|
||||
Log In
|
||||
{t('auth.login.submit')}
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
@@ -596,14 +598,14 @@ export function RegisterPage() {
|
||||
) : (
|
||||
<div key="step2" className={`w-full${direction === 'forward' ? ' animate-step-forward' : ''}`}>
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-txt-primary">Make it yours</h1>
|
||||
<p className="text-txt-tertiary text-sm mt-1">Personalize your profile, or skip for now</p>
|
||||
<h1 className="text-2xl font-bold text-txt-primary">{t('auth.register.profile.title')}</h1>
|
||||
<p className="text-txt-tertiary text-sm mt-1">{t('auth.register.profile.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{retryAfter > 0 && (
|
||||
<div className="mb-4 p-3 bg-accent-amber/10 border border-accent-amber/30 rounded text-sm">
|
||||
<p className="font-medium text-accent-amber">Too many attempts</p>
|
||||
<p className="text-txt-secondary mt-0.5">Try again in {retryAfter}s</p>
|
||||
<p className="font-medium text-accent-amber">{t('auth.register.rateLimited')}</p>
|
||||
<p className="text-txt-secondary mt-0.5">{t('auth.retryIn', { seconds: retryAfter })}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -652,13 +654,13 @@ export function RegisterPage() {
|
||||
{/* Display Name */}
|
||||
<div className="mb-5">
|
||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||
Display Name
|
||||
{t('auth.register.displayName')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder={username.trim() || 'Display name'}
|
||||
placeholder={username.trim() || t('auth.register.displayName.placeholder')}
|
||||
className="input-standard w-full py-2.5 text-base md:text-sm"
|
||||
autoComplete="name"
|
||||
/>
|
||||
@@ -667,7 +669,7 @@ export function RegisterPage() {
|
||||
{/* Avatar Color Picker */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||
Avatar Color
|
||||
{t('auth.register.avatarColor')}
|
||||
</label>
|
||||
{/* Color swatch row: gap tightens on narrow viewports so the 7 swatches
|
||||
fit inside a 360 px viewport (p-6 inner content area is ~280 px;
|
||||
@@ -701,10 +703,10 @@ export function RegisterPage() {
|
||||
className="w-full py-3 md:py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{retryAfter > 0
|
||||
? `Try again in ${retryAfter}s`
|
||||
? t('auth.retryIn', { seconds: retryAfter })
|
||||
: isRegistering
|
||||
? 'Creating account...'
|
||||
: 'Get Started'}
|
||||
? t('auth.register.creating')
|
||||
: t('auth.register.submit')}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
@@ -715,7 +717,7 @@ export function RegisterPage() {
|
||||
// py-2 px-1 widens the tap area on mobile while keeping the visual link style.
|
||||
className="text-sm text-txt-tertiary hover:text-txt-secondary transition-colors disabled:opacity-50 py-2 px-1 -mx-1"
|
||||
>
|
||||
Back
|
||||
{t('auth.register.back')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -723,7 +725,7 @@ export function RegisterPage() {
|
||||
disabled={isDisabled}
|
||||
className="text-sm text-txt-tertiary hover:text-txt-secondary transition-colors disabled:opacity-50 py-2 px-1 -mx-1"
|
||||
>
|
||||
Skip for now
|
||||
{t('auth.register.skip')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -745,7 +747,7 @@ export function RegisterPage() {
|
||||
onClose={() => setAvatarCropSrc(null)}
|
||||
imageSrc={avatarCropSrc}
|
||||
onCropComplete={handleAvatarCropComplete}
|
||||
title="Crop Avatar"
|
||||
title={t('auth.register.cropAvatar')}
|
||||
aspectRatio={1}
|
||||
cropShape="round"
|
||||
maxOutputDimension={256}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import React, { useRef, useEffect, useMemo } from 'react';
|
||||
import { useSpaceStore } from '../../stores/spaceStore';
|
||||
import { useExpressionStore } from '../../stores/expressionStore';
|
||||
import { api } from '../../api/client';
|
||||
import Picker from '@emoji-mart/react';
|
||||
import data from '@emoji-mart/data';
|
||||
|
||||
@@ -16,6 +19,24 @@ interface EmojiPickerProps {
|
||||
export function EmojiPicker({ onEmojiSelect, mobile = false }: EmojiPickerProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Emojis próprios do servidor entram como categoria extra do emoji-mart.
|
||||
// Sem espaço atual (DM) a lista fica vazia e a categoria não aparece.
|
||||
const spaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||
const spaceEmojis = useExpressionStore((s) => (spaceId ? s.emojisBySpace.get(spaceId) : undefined));
|
||||
const customCategories = useMemo(() => {
|
||||
if (!spaceEmojis?.length) return [];
|
||||
return [{
|
||||
id: 'space',
|
||||
name: 'Servidor',
|
||||
emojis: spaceEmojis.map((e) => ({
|
||||
id: e.name,
|
||||
name: e.name,
|
||||
keywords: [e.name],
|
||||
skins: [{ src: api.uploads.url(e.filename) }],
|
||||
})),
|
||||
}];
|
||||
}, [spaceEmojis]);
|
||||
|
||||
// Prevent keyboard events from bubbling out (e.g. Enter submitting the chat input)
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
@@ -45,6 +66,7 @@ export function EmojiPicker({ onEmojiSelect, mobile = false }: EmojiPickerProps)
|
||||
<div ref={containerRef} className={wrapperClass}>
|
||||
<Picker
|
||||
data={data}
|
||||
custom={customCategories}
|
||||
onEmojiSelect={onEmojiSelect}
|
||||
theme="dark"
|
||||
set="native"
|
||||
|
||||
@@ -8,8 +8,10 @@ import { getSpaceGradient } from '../../utils/gradients';
|
||||
import { extractDominantColors, colorsToGradient } from '../../utils/colorExtractor';
|
||||
import { MemberListToggleButton } from '../layout/MemberListToggleButton';
|
||||
import { useSpaceJoin } from '../../hooks/useSpaceJoin';
|
||||
import { useT } from '../../i18n';
|
||||
|
||||
export function ExplorePage() {
|
||||
const t = useT();
|
||||
const navigate = useNavigate();
|
||||
const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace);
|
||||
|
||||
@@ -73,7 +75,7 @@ export function ExplorePage() {
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-5.5-2.5l7.51-3.49L17.5 6.5 9.99 9.99 6.5 17.5zm5.5-6.6c.61 0 1.1.49 1.1 1.1s-.49 1.1-1.1 1.1-1.1-.49-1.1-1.1.49-1.1 1.1-1.1z" />
|
||||
</svg>
|
||||
<span className="font-bold text-txt-primary">Explore</span>
|
||||
<span className="font-bold text-txt-primary">{t('explore.title')}</span>
|
||||
</div>
|
||||
|
||||
<div className="w-[1px] h-6 bg-surface-elevated mx-2" />
|
||||
@@ -81,7 +83,7 @@ export function ExplorePage() {
|
||||
<div className="relative flex-1 max-w-xs ml-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search spaces..."
|
||||
placeholder={t('explore.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
className="input-search w-full"
|
||||
@@ -107,7 +109,7 @@ export function ExplorePage() {
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{!discoveryEnabled && (
|
||||
<div className="mx-6 mt-4 p-2.5 bg-accent-amber/10 border border-accent-amber/30 rounded text-[13px] text-accent-amber">
|
||||
Space discovery is disabled by the instance administrator.
|
||||
{t('explore.disabled')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -124,9 +126,7 @@ export function ExplorePage() {
|
||||
<div className="flex flex-col items-center justify-center h-64 opacity-80">
|
||||
<Mascot state="lonely" className="w-32 h-32 mb-3" />
|
||||
<p className="text-txt-tertiary text-sm">
|
||||
{searchQuery
|
||||
? 'No spaces match your search.'
|
||||
: 'No discoverable spaces yet.'}
|
||||
{searchQuery ? t('explore.empty.search') : t('explore.empty.none')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -138,7 +138,7 @@ export function ExplorePage() {
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
||||
</svg>
|
||||
<span className="text-[13px] text-accent-mint">
|
||||
You're in all discoverable spaces
|
||||
{t('explore.allJoined')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -173,7 +173,7 @@ export function ExplorePage() {
|
||||
<path d="M7 10l5 5 5-5z" />
|
||||
</svg>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-txt-tertiary group-hover:text-txt-secondary transition-colors">
|
||||
Joined
|
||||
{t('explore.joined')}
|
||||
</span>
|
||||
<span className="text-xs text-txt-tertiary/60">
|
||||
{joinedSpaces.length}
|
||||
@@ -207,6 +207,7 @@ function SpaceCard({
|
||||
space: TaggedExploreSpace;
|
||||
onJoinSuccess: (spaceId: string) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const {
|
||||
isJoined,
|
||||
isPublic,
|
||||
@@ -297,7 +298,7 @@ function SpaceCard({
|
||||
? 'bg-accent-mint/20 text-accent-mint'
|
||||
: 'bg-accent-amber/20 text-accent-amber'
|
||||
}`}>
|
||||
{isPublic ? 'Public' : 'Request'}
|
||||
{isPublic ? t('explore.badge.public') : t('explore.badge.request')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -330,7 +331,7 @@ function SpaceCard({
|
||||
{space.description}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[13px] text-txt-tertiary italic mb-3 flex-1">No description</p>
|
||||
<p className="text-[13px] text-txt-tertiary italic mb-3 flex-1">{t('explore.noDescription')}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 text-[12px] text-txt-tertiary mb-3">
|
||||
@@ -338,7 +339,7 @@ function SpaceCard({
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="opacity-60">
|
||||
<path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z" />
|
||||
</svg>
|
||||
{space.memberCount} {space.memberCount === 1 ? 'member' : 'members'}
|
||||
{space.memberCount} {t(space.memberCount === 1 ? 'explore.member' : 'explore.members')}
|
||||
</span>
|
||||
{originLabel && (
|
||||
<span className="flex items-center gap-1 text-txt-tertiary/70">
|
||||
@@ -360,7 +361,7 @@ function SpaceCard({
|
||||
onClick={handleViewSpace}
|
||||
className="w-full py-2 bg-accent-mint/15 hover:bg-accent-mint/25 text-accent-mint text-sm font-medium rounded transition-colors"
|
||||
>
|
||||
View Space
|
||||
{t('explore.view')}
|
||||
</button>
|
||||
) : isPublic ? (
|
||||
<button
|
||||
@@ -371,10 +372,10 @@ function SpaceCard({
|
||||
{joining ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<LoadingSpinner />
|
||||
Joining...
|
||||
{t('explore.joining')}
|
||||
</span>
|
||||
) : (
|
||||
'Join Space'
|
||||
t('explore.join')
|
||||
)}
|
||||
</button>
|
||||
) : isPending ? (
|
||||
@@ -382,14 +383,14 @@ function SpaceCard({
|
||||
disabled
|
||||
className="w-full py-2 bg-interactive-muted text-txt-tertiary text-sm font-medium rounded cursor-default"
|
||||
>
|
||||
Request Pending
|
||||
{t('explore.requestPending')}
|
||||
</button>
|
||||
) : showRequestForm ? (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={requestMessage}
|
||||
onChange={(e) => setRequestMessage(e.target.value.slice(0, 200))}
|
||||
placeholder="Why do you want to join? (optional)"
|
||||
placeholder={t('explore.request.placeholder')}
|
||||
rows={2}
|
||||
className="input-standard w-full resize-none"
|
||||
/>
|
||||
@@ -399,13 +400,13 @@ function SpaceCard({
|
||||
disabled={joining}
|
||||
className="flex-1 py-1.5 bg-accent-amber hover:bg-accent-amber/80 text-[#13131a] text-sm font-medium rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{joining ? 'Sending...' : 'Send Request'}
|
||||
{joining ? t('explore.request.sending') : t('explore.request.send')}
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelRequestForm}
|
||||
className="px-3 py-1.5 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||
>
|
||||
Cancel
|
||||
{t('explore.request.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -414,7 +415,7 @@ function SpaceCard({
|
||||
onClick={openRequestForm}
|
||||
className="w-full py-2 bg-accent-amber/20 hover:bg-accent-amber/30 text-accent-amber text-sm font-medium rounded transition-colors"
|
||||
>
|
||||
Request to Join
|
||||
{t('explore.requestToJoin')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -22,8 +22,14 @@ import { parseFederatedUsername, isFederationGlobeApplicable } from '../../utils
|
||||
import { useCanonicalUserView } from '../../utils/userViewLookup';
|
||||
import { Username } from '../ui/Username';
|
||||
import { ConfirmDialog } from '../ui/ConfirmDialog';
|
||||
import { useT, type TranslationKey } from '../../i18n';
|
||||
|
||||
const statusLabel: Record<string, string> = { online: 'Online', idle: 'Idle', dnd: 'Do Not Disturb', offline: 'Offline' };
|
||||
const statusLabelKey: Record<string, TranslationKey> = {
|
||||
online: 'friends.status.online',
|
||||
idle: 'friends.status.idle',
|
||||
dnd: 'friends.status.dnd',
|
||||
offline: 'friends.status.offline',
|
||||
};
|
||||
|
||||
function ActivityFriendItem({
|
||||
friend,
|
||||
@@ -98,6 +104,7 @@ interface FriendsPageProps {
|
||||
}
|
||||
|
||||
export function FriendsPage({ mobile }: FriendsPageProps) {
|
||||
const t = useT();
|
||||
const [activeTab, setActiveTab] = useState<Tab>('online');
|
||||
const [pendingUnfriend, setPendingUnfriend] = useState<{ id: string; name: string } | null>(null);
|
||||
const navigate = useNavigate();
|
||||
@@ -172,12 +179,12 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<h2 className="text-xs font-bold text-txt-tertiary mb-4 tracking-wider px-2">
|
||||
Online — {onlineFriends.length}
|
||||
{t('friends.heading.online', { count: onlineFriends.length })}
|
||||
</h2>
|
||||
{onlineFriends.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full opacity-80">
|
||||
<Mascot state="idle" className="w-32 h-32 mb-4" />
|
||||
<p className="text-txt-tertiary text-sm">No one's online right now.</p>
|
||||
<p className="text-txt-tertiary text-sm">{t('friends.empty.online')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -192,12 +199,12 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<h2 className="text-xs font-bold text-txt-tertiary mb-4 tracking-wider px-2">
|
||||
All Friends — {friends.length}
|
||||
{t('friends.heading.all', { count: friends.length })}
|
||||
</h2>
|
||||
{friends.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full opacity-80">
|
||||
<Mascot state="lonely" className="w-32 h-32 mb-4" />
|
||||
<p className="text-txt-tertiary text-sm">No friends yet — add someone!</p>
|
||||
<p className="text-txt-tertiary text-sm">{t('friends.empty.all')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -212,12 +219,12 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<h2 className="text-xs font-bold text-txt-tertiary mb-4 tracking-wider px-2">
|
||||
Pending — {pendingIncoming.length + pendingOutgoing.length}
|
||||
{t('friends.heading.pending', { count: pendingIncoming.length + pendingOutgoing.length })}
|
||||
</h2>
|
||||
{[...pendingIncoming, ...pendingOutgoing].length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full opacity-80">
|
||||
<Mascot state="sleeping" className="w-32 h-32 mb-4" />
|
||||
<p className="text-txt-tertiary text-sm">No pending requests — Nori is napping.</p>
|
||||
<p className="text-txt-tertiary text-sm">{t('friends.empty.pending')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -292,7 +299,7 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Mascot state="sleeping" className="w-[100px] h-[100px]" />
|
||||
<div className="text-sm text-txt-tertiary mt-4 max-w-[240px]">
|
||||
It's quiet for now... When friends start an activity, we'll show it here!
|
||||
{t('friends.empty.activity')}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -300,7 +307,7 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
|
||||
{activeFriends.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h2 className="text-xs font-bold text-txt-tertiary mb-2 tracking-wider px-2">
|
||||
Active — {activeFriends.length}
|
||||
{t('friends.heading.active', { count: activeFriends.length })}
|
||||
</h2>
|
||||
{activeFriends.map(f => renderActivityFriend(f))}
|
||||
</div>
|
||||
@@ -308,7 +315,7 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
|
||||
{idleFriends.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h2 className="text-xs font-bold text-txt-tertiary mb-2 tracking-wider px-2">
|
||||
Online — {idleFriends.length}
|
||||
{t('friends.heading.online', { count: idleFriends.length })}
|
||||
</h2>
|
||||
{idleFriends.map(f => renderActivityFriend(f))}
|
||||
</div>
|
||||
@@ -316,7 +323,7 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
|
||||
{offlineActivityFriends.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-xs font-bold text-txt-tertiary mb-2 tracking-wider px-2">
|
||||
Offline — {offlineActivityFriends.length}
|
||||
{t('friends.heading.offline', { count: offlineActivityFriends.length })}
|
||||
</h2>
|
||||
{offlineActivityFriends.map(f => renderActivityFriend(f, true))}
|
||||
</div>
|
||||
@@ -341,7 +348,7 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="font-semibold text-sm text-txt-primary">Friends</span>
|
||||
<span className="font-semibold text-sm text-txt-primary">{t('friends.title')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-14 px-4 flex items-center border-b border-border-hard flex-shrink-0 z-10 bg-surface-chat">
|
||||
@@ -349,14 +356,14 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary">
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||
</svg>
|
||||
<span className="font-bold text-txt-primary">Friends</span>
|
||||
<span className="font-bold text-txt-primary">{t('friends.title')}</span>
|
||||
</div>
|
||||
<div className="w-[1px] h-6 bg-surface-elevated mx-2" />
|
||||
<div className="flex items-center gap-4 ml-2">
|
||||
<TabButton active={activeTab === 'online'} onClick={() => setActiveTab('online')}>Online</TabButton>
|
||||
<TabButton active={activeTab === 'all'} onClick={() => setActiveTab('all')}>All</TabButton>
|
||||
<TabButton active={activeTab === 'online'} onClick={() => setActiveTab('online')}>{t('friends.tab.online')}</TabButton>
|
||||
<TabButton active={activeTab === 'all'} onClick={() => setActiveTab('all')}>{t('friends.tab.all')}</TabButton>
|
||||
<TabButton active={activeTab === 'pending'} onClick={() => setActiveTab('pending')}>
|
||||
Pending
|
||||
{t('friends.tab.pending')}
|
||||
{(pendingIncoming.length > 0) && (
|
||||
<span className="ml-2 px-1.5 py-0.5 bg-accent-rose text-white text-[10px] rounded-full leading-none">
|
||||
{pendingIncoming.length}
|
||||
@@ -369,7 +376,7 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
|
||||
activeTab === 'add' ? 'text-status-online bg-transparent' : 'bg-status-online text-[#13131a] hover:bg-status-online/90'
|
||||
}`}
|
||||
>
|
||||
Add Friend
|
||||
{t('friends.tab.add')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
@@ -391,7 +398,7 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
|
||||
: 'border-transparent text-txt-secondary hover:text-txt-primary'
|
||||
}`}
|
||||
>
|
||||
{tab === 'add' ? 'Add Friend' : tab === 'activity' ? 'Activity' : tab.charAt(0).toUpperCase() + tab.slice(1)}
|
||||
{t(`friends.tab.${tab}` as TranslationKey)}
|
||||
{tab === 'pending' && pendingIncoming.length > 0 && (
|
||||
<span className="ml-1.5 px-1.5 py-0.5 text-xs bg-notification text-white rounded-full">
|
||||
{pendingIncoming.length}
|
||||
@@ -413,9 +420,11 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
|
||||
setPendingUnfriend(null);
|
||||
}
|
||||
}}
|
||||
title="Remove Friend"
|
||||
description={`Are you sure you want to remove ${pendingUnfriend?.name ?? 'this user'} as a friend? You can always send them a new friend request later.`}
|
||||
confirmLabel="Remove"
|
||||
title={t('friends.remove.title')}
|
||||
description={t('friends.remove.body', {
|
||||
name: pendingUnfriend?.name ?? t('friends.remove.someone'),
|
||||
})}
|
||||
confirmLabel={t('friends.remove.confirm')}
|
||||
variant="danger"
|
||||
/>
|
||||
</div>
|
||||
@@ -429,6 +438,7 @@ function AddFriendTab({
|
||||
}: {
|
||||
onOpenDm: (userId: string, homeUserId?: string, homeInstance?: string | null) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const searchUsers = useSocialStore((s) => s.searchUsers);
|
||||
const sendFriendRequest = useSocialStore((s) => s.sendFriendRequest);
|
||||
const friends = useSocialStore((s) => s.friends);
|
||||
@@ -527,7 +537,7 @@ function AddFriendTab({
|
||||
setDirectAddLoading(true);
|
||||
try {
|
||||
await sendFriendRequest(query.trim());
|
||||
addToast('Friend request sent!', 'success');
|
||||
addToast(t('friends.add.sent'), 'success');
|
||||
setQuery('');
|
||||
} catch (err) {
|
||||
// The shared API client throws `new Error(body.error)` for non-2xx
|
||||
@@ -548,8 +558,8 @@ function AddFriendTab({
|
||||
const displayUsers = isSearchMode ? enrichedSearchResults : discoverUsers;
|
||||
const displayLoading = isSearchMode ? searchLoading : discoverLoading;
|
||||
const emptyLabel = isSearchMode
|
||||
? 'No users match your search.'
|
||||
: 'No discoverable users yet — invite people to join!';
|
||||
? t('friends.add.empty.search')
|
||||
: t('friends.add.empty.discover');
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
@@ -559,15 +569,29 @@ function AddFriendTab({
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary">
|
||||
<path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||
</svg>
|
||||
<h2 className="text-base font-bold text-txt-primary">Find People</h2>
|
||||
<h2 className="text-base font-bold text-txt-primary">{t('friends.add.title')}</h2>
|
||||
</div>
|
||||
<p className="text-sm text-txt-tertiary mb-4">Search by username or use <span className="font-medium text-txt-secondary">user@instance</span> to add someone directly.</p>
|
||||
<p className="text-sm text-txt-tertiary mb-4">
|
||||
{/* The `user@instance` example is emphasised, so the sentence is
|
||||
split around its placeholder rather than interpolated as text —
|
||||
translations keep control of the word order either side of it. */}
|
||||
{(() => {
|
||||
const [before, after] = t('friends.add.hint').split('{handle}');
|
||||
return (
|
||||
<>
|
||||
{before}
|
||||
<span className="font-medium text-txt-secondary">user@instance</span>
|
||||
{after}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</p>
|
||||
|
||||
{/* Unified search input */}
|
||||
<div className="relative mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search or add by username..."
|
||||
placeholder={t('friends.add.searchPlaceholder')}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="input-search w-full px-4 py-3 rounded-lg"
|
||||
@@ -591,14 +615,14 @@ function AddFriendTab({
|
||||
<path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0 text-sm text-txt-secondary">
|
||||
Send friend request to <span className="font-semibold text-txt-primary">{directAddDisplay}</span>
|
||||
{t('friends.add.directTo')} <span className="font-semibold text-txt-primary">{directAddDisplay}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDirectAdd}
|
||||
disabled={directAddLoading}
|
||||
className="px-3 py-1.5 rounded-md bg-accent-primary hover:bg-accent-primary-hover text-white text-sm font-medium transition-colors disabled:opacity-50 flex-shrink-0"
|
||||
>
|
||||
{directAddLoading ? 'Sending...' : 'Send Request'}
|
||||
{directAddLoading ? t('friends.add.sending') : t('friends.add.send')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -611,7 +635,7 @@ function AddFriendTab({
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-5.5-2.5l7.51-3.49L17.5 6.5 9.99 9.99 6.5 17.5zm5.5-6.6c.61 0 1.1.49 1.1 1.1s-.49 1.1-1.1 1.1-1.1-.49-1.1-1.1.49-1.1 1.1-1.1z" />
|
||||
</svg>
|
||||
<span className="text-xs font-semibold tracking-wider text-txt-tertiary">Discover People</span>
|
||||
<span className="text-xs font-semibold tracking-wider text-txt-tertiary">{t('friends.add.discover')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -665,6 +689,7 @@ function UserDiscoverCard({
|
||||
onOpenDm: (userId: string, homeUserId?: string, homeInstance?: string | null) => void;
|
||||
onRelationshipChange: (userId: string, origin: string, relationship: TaggedDiscoverUser['relationship'], requestId?: string) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const sendFriendRequest = useSocialStore((s) => s.sendFriendRequest);
|
||||
const updateFriendRequest = useSocialStore((s) => s.updateFriendRequest);
|
||||
const openModal = useUIStore((s) => s.openModal);
|
||||
@@ -695,7 +720,7 @@ function UserDiscoverCard({
|
||||
} catch (err) {
|
||||
// See handleDirectAdd above — err.message is the server error code.
|
||||
const code = err instanceof Error ? err.message : undefined;
|
||||
setError(mapServerErrorToMessage(code, code ?? 'Failed to send request', username));
|
||||
setError(mapServerErrorToMessage(code, code ?? t('friends.add.failed'), username));
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
@@ -709,7 +734,7 @@ function UserDiscoverCard({
|
||||
await updateFriendRequest(user.requestId, 'accepted');
|
||||
onRelationshipChange(user.id, user._instanceOrigin, 'friends');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to accept request');
|
||||
setError(err instanceof Error ? err.message : t('friends.accept.failed'));
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
@@ -723,7 +748,7 @@ function UserDiscoverCard({
|
||||
await updateFriendRequest(user.requestId, 'declined');
|
||||
onRelationshipChange(user.id, user._instanceOrigin, 'none');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to decline request');
|
||||
setError(err instanceof Error ? err.message : t('friends.decline.failed'));
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
@@ -741,7 +766,7 @@ function UserDiscoverCard({
|
||||
await client.social.cancelRequest(user.requestId);
|
||||
onRelationshipChange(user.id, user._instanceOrigin, 'none');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to cancel request');
|
||||
setError(err instanceof Error ? err.message : t('friends.cancel.failed'));
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
@@ -840,7 +865,7 @@ function UserDiscoverCard({
|
||||
disabled={actionLoading}
|
||||
className="w-full py-1.5 bg-accent-primary hover:bg-accent-primary-hover text-white text-[13px] font-medium rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{actionLoading ? 'Sending...' : 'Send Friend Request'}
|
||||
{actionLoading ? t('friends.add.sending') : t('friends.add.sendRequest')}
|
||||
</button>
|
||||
)}
|
||||
{user.relationship === 'outbound_pending' && (
|
||||
@@ -849,7 +874,7 @@ function UserDiscoverCard({
|
||||
disabled={actionLoading || !user.requestId}
|
||||
className="w-full py-1.5 bg-accent-amber/15 hover:bg-accent-amber/25 text-accent-amber text-[13px] font-medium rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{actionLoading ? 'Cancelling...' : 'Request Pending'}
|
||||
{actionLoading ? t('friends.add.cancelling') : t('friends.add.pending')}
|
||||
</button>
|
||||
)}
|
||||
{user.relationship === 'inbound_pending' && (
|
||||
@@ -899,6 +924,7 @@ function TabButton({ children, active, onClick }: { children: React.ReactNode, a
|
||||
}
|
||||
|
||||
function FriendItem({ friend, onRemove, onDm }: { friend: TaggedFriend, onRemove: () => void, onDm: () => void }) {
|
||||
const t = useT();
|
||||
const canonical = useCanonicalUserView(friend as unknown as User);
|
||||
const instanceLabel = friend._instanceOrigin ? (() => { try { return new URL(friend._instanceOrigin).host; } catch { return friend._instanceOrigin; } })() : '';
|
||||
const { baseName: friendBaseName } = parseFederatedUsername(canonical.username);
|
||||
@@ -913,7 +939,7 @@ function FriendItem({ friend, onRemove, onDm }: { friend: TaggedFriend, onRemove
|
||||
<span className="text-txt-tertiary text-[13px] opacity-60 group-hover:opacity-100 transition-opacity font-medium">@{friend.username}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[12px] text-txt-tertiary font-medium">{statusLabel[friend.status] ?? friend.status}</span>
|
||||
<span className="text-[12px] text-txt-tertiary font-medium">{statusLabelKey[friend.status] ? t(statusLabelKey[friend.status]!) : friend.status}</span>
|
||||
{instanceLabel && (
|
||||
<span className="text-[11px] text-txt-tertiary/60 font-medium">via {instanceLabel}</span>
|
||||
)}
|
||||
@@ -924,7 +950,7 @@ function FriendItem({ friend, onRemove, onDm }: { friend: TaggedFriend, onRemove
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDm(); }}
|
||||
className="w-9 h-9 flex items-center justify-center bg-surface-base rounded-full text-txt-tertiary hover:text-txt-primary transition-colors"
|
||||
title="Message"
|
||||
title={t('friends.action.message')}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM6 9h12v2H6V9zm8 5H6v-2h8v2zm4-5H6V7h12v2z" />
|
||||
@@ -933,7 +959,7 @@ function FriendItem({ friend, onRemove, onDm }: { friend: TaggedFriend, onRemove
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onRemove(); }}
|
||||
className="w-9 h-9 flex items-center justify-center bg-surface-base rounded-full text-txt-tertiary hover:text-txt-danger transition-colors"
|
||||
title="Remove Friend"
|
||||
title={t('friends.action.remove')}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
|
||||
@@ -951,6 +977,7 @@ function RequestItem({ request, type, onAccept, onDecline, onCancel }: {
|
||||
onDecline?: () => void;
|
||||
onCancel?: () => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const rawUser = request.user;
|
||||
const _FALLBACK_USER = { id: '', username: '', createdAt: 0, isAdmin: false, replicatedInstances: [] } as unknown as User;
|
||||
const canonicalUser = useCanonicalUserView((rawUser as unknown as User | null) ?? _FALLBACK_USER);
|
||||
@@ -970,7 +997,7 @@ function RequestItem({ request, type, onAccept, onDecline, onCancel }: {
|
||||
<span className="text-txt-tertiary text-xs">@{user.username}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-txt-tertiary">{type === 'incoming' ? 'Incoming Friend Request' : 'Outgoing Friend Request'}</span>
|
||||
<span className="text-xs text-txt-tertiary">{t(type === 'incoming' ? 'friends.request.incoming' : 'friends.request.outgoing')}</span>
|
||||
{instanceLabel && (
|
||||
<span className="text-[11px] text-txt-tertiary/60 font-medium">via {instanceLabel}</span>
|
||||
)}
|
||||
@@ -983,7 +1010,7 @@ function RequestItem({ request, type, onAccept, onDecline, onCancel }: {
|
||||
<button
|
||||
onClick={() => onAccept?.()}
|
||||
className="p-2 bg-surface-base rounded-full text-status-online hover:bg-status-online hover:text-white transition-all"
|
||||
title="Accept"
|
||||
title={t('friends.action.accept')}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
@@ -992,7 +1019,7 @@ function RequestItem({ request, type, onAccept, onDecline, onCancel }: {
|
||||
<button
|
||||
onClick={() => onDecline?.()}
|
||||
className="p-2 bg-surface-base rounded-full text-txt-danger hover:bg-accent-rose hover:text-white transition-all"
|
||||
title="Decline"
|
||||
title={t('friends.action.decline')}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
|
||||
@@ -1003,7 +1030,7 @@ function RequestItem({ request, type, onAccept, onDecline, onCancel }: {
|
||||
<button
|
||||
onClick={() => onCancel?.()}
|
||||
className="p-2 bg-surface-base rounded-full text-txt-tertiary hover:text-txt-danger transition-all"
|
||||
title="Cancel Request"
|
||||
title={t('friends.action.cancel')}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React, { useRef, useEffect, useCallback } from 'react';
|
||||
import { useT } from '../../i18n';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { EmojiPicker } from './EmojiPicker';
|
||||
import { GifPicker } from './GifPicker';
|
||||
import { StickerPicker } from './StickerPicker';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useDragToClose } from '../../hooks/useDragToClose';
|
||||
|
||||
export type InputPopoverTab = 'emoji' | 'gif';
|
||||
export type InputPopoverTab = 'emoji' | 'gif' | 'sticker';
|
||||
|
||||
interface InputPopoverProps {
|
||||
activeTab: InputPopoverTab;
|
||||
@@ -15,6 +17,8 @@ interface InputPopoverProps {
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
gifEnabled: boolean;
|
||||
onTabChange: (tab: InputPopoverTab) => void;
|
||||
onStickerSelect: (stickerId: string) => void;
|
||||
hasStickers?: boolean;
|
||||
}
|
||||
|
||||
interface SharedTabProps {
|
||||
@@ -53,6 +57,7 @@ function DesktopPopover({
|
||||
onClose,
|
||||
onEmojiSelect,
|
||||
onGifSelect,
|
||||
onStickerSelect,
|
||||
anchorRef,
|
||||
gifEnabled,
|
||||
onTabChange,
|
||||
@@ -139,6 +144,7 @@ function DesktopPopover({
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{activeTab === 'emoji' && <EmojiPicker onEmojiSelect={onEmojiSelect} />}
|
||||
{activeTab === 'gif' && gifEnabled && <GifPicker onGifSelect={onGifSelect} />}
|
||||
{activeTab === 'sticker' && <StickerPicker onStickerSelect={onStickerSelect} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
@@ -155,6 +161,7 @@ function MobileSheet({
|
||||
onClose,
|
||||
onEmojiSelect,
|
||||
onGifSelect,
|
||||
onStickerSelect,
|
||||
gifEnabled,
|
||||
onTabChange,
|
||||
availableTabs,
|
||||
@@ -221,6 +228,7 @@ function MobileSheet({
|
||||
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
|
||||
{activeTab === 'emoji' && <EmojiPicker onEmojiSelect={onEmojiSelect} mobile />}
|
||||
{activeTab === 'gif' && gifEnabled && <GifPicker onGifSelect={onGifSelect} mobile />}
|
||||
{activeTab === 'sticker' && <StickerPicker onStickerSelect={onStickerSelect} mobile />}
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
@@ -231,12 +239,17 @@ function MobileSheet({
|
||||
export function InputPopover(props: InputPopoverProps) {
|
||||
const isMobile = useUIStore((s) => s.isMobile);
|
||||
|
||||
const t = useT();
|
||||
const availableTabs: { key: InputPopoverTab; label: string }[] = [
|
||||
{ key: 'emoji', label: 'Emoji' },
|
||||
];
|
||||
if (props.gifEnabled) {
|
||||
availableTabs.splice(0, 0, { key: 'gif', label: 'GIF' });
|
||||
}
|
||||
// Figurinha só existe dentro de um servidor; em DM a aba não aparece.
|
||||
if (props.hasStickers) {
|
||||
availableTabs.push({ key: 'sticker', label: t('expressions.stickers') });
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return <MobileSheet {...props} availableTabs={availableTabs} />;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { isCustomEmojiAlt } from '../../utils/customEmoji';
|
||||
import ReactMarkdown, { defaultUrlTransform } from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Highlight, themes } from 'prism-react-renderer';
|
||||
@@ -186,7 +187,22 @@ function buildComponents(): Components {
|
||||
hr: () => <hr className="border-border-soft my-2" />,
|
||||
|
||||
// Images (in markdown content — not attachments)
|
||||
img: ({ src, alt }) => (
|
||||
img: ({ src, alt }) => {
|
||||
// Emoji próprio do espaço chega como imagem markdown com alt `:nome:`.
|
||||
// Renderiza em tamanho de texto e inline, para caber no meio da frase em
|
||||
// vez de virar um bloco como uma imagem comum.
|
||||
if (isCustomEmojiAlt(alt ?? undefined)) {
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt ?? ''}
|
||||
title={alt ?? ''}
|
||||
className="inline-block align-text-bottom w-[22px] h-[22px] object-contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="mt-1 max-w-[400px]">
|
||||
<img
|
||||
src={src}
|
||||
@@ -197,7 +213,8 @@ function buildComponents(): Components {
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
// Tables (GFM)
|
||||
table: ({ children }) => (
|
||||
|
||||
@@ -3,17 +3,21 @@ import type { User } from '@backspace/shared';
|
||||
import { useSpaceStore } from '../../stores/spaceStore';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useCanonicalUserView } from '../../utils/userViewLookup';
|
||||
import { EVERYONE_MENTION_ID } from '@backspace/shared/src/mentions';
|
||||
import { useT } from '../../i18n';
|
||||
|
||||
interface MentionBadgeProps {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export const MentionBadge = React.memo(function MentionBadge({ userId }: MentionBadgeProps) {
|
||||
const t = useT();
|
||||
const members = useSpaceStore((s) => s.members);
|
||||
const spaces = useSpaceStore((s) => s.spaces);
|
||||
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
||||
|
||||
const isEveryone = userId === EVERYONE_MENTION_ID;
|
||||
const member = members.find((m) => m.userId === userId);
|
||||
const space = spaces.find((s) => s.id === currentSpaceId);
|
||||
const ownerId = space?.ownerId;
|
||||
@@ -25,7 +29,12 @@ export const MentionBadge = React.memo(function MentionBadge({ userId }: Mention
|
||||
let displayName: string;
|
||||
let color: string;
|
||||
|
||||
if (member && memberUser) {
|
||||
if (isEveryone) {
|
||||
// Amber, matching the highlight the mention puts on the message row, so the
|
||||
// badge and the row read as one signal rather than two unrelated colours.
|
||||
displayName = t('mention.everyone.label');
|
||||
color = '#fbbf24';
|
||||
} else if (member && memberUser) {
|
||||
displayName = memberUser.displayName ?? memberUser.username;
|
||||
if (member.roles && member.roles.length > 0) {
|
||||
const sorted = [...member.roles].sort((a, b) => b.position - a.position);
|
||||
@@ -36,12 +45,12 @@ export const MentionBadge = React.memo(function MentionBadge({ userId }: Mention
|
||||
color = '#7c6cf6'; // accent-primary default
|
||||
}
|
||||
} else {
|
||||
displayName = 'Unknown User';
|
||||
displayName = t('mention.unknownUser');
|
||||
color = '#a0a0aa'; // text-secondary fallback
|
||||
}
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (!member || !memberUser) return;
|
||||
if (isEveryone || !member || !memberUser) return;
|
||||
e.stopPropagation();
|
||||
openUserProfile(memberUser, e.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
@@ -52,7 +61,9 @@ export const MentionBadge = React.memo(function MentionBadge({ userId }: Mention
|
||||
return (
|
||||
<span
|
||||
onClick={handleClick}
|
||||
className="inline-flex items-center rounded-[3px] px-[2px] font-medium cursor-pointer transition-colors hover:brightness-125"
|
||||
className={`inline-flex items-center rounded-[3px] px-[2px] font-medium transition-colors ${
|
||||
isEveryone ? '' : 'cursor-pointer hover:brightness-125'
|
||||
}`}
|
||||
style={{ color, backgroundColor: bgColor }}
|
||||
>
|
||||
@{displayName}
|
||||
|
||||
@@ -6,6 +6,16 @@ import { useSpaceStore } from '../../stores/spaceStore';
|
||||
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
|
||||
import { useCanonicalUserView } from '../../utils/userViewLookup';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useT } from '../../i18n';
|
||||
|
||||
/**
|
||||
* What the composer gets back when a row is picked. @everyone is not a member,
|
||||
* so the callback takes a tagged entry rather than a `MemberWithUser` — that
|
||||
* keeps the two cases apart at the type level instead of by a sentinel id.
|
||||
*/
|
||||
export type MentionEntry =
|
||||
| { kind: 'everyone' }
|
||||
| { kind: 'member'; member: MemberWithUser };
|
||||
|
||||
const MAX_RESULTS = 8;
|
||||
|
||||
@@ -64,17 +74,60 @@ function MentionMemberRow({
|
||||
interface MentionPopoverProps {
|
||||
query: string;
|
||||
selectedIndex: number;
|
||||
onSelect: (member: MemberWithUser) => void;
|
||||
onSelect: (entry: MentionEntry) => void;
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
/** Whether the @everyone row is offered — mirrors MENTION_EVERYONE. */
|
||||
showEveryone: boolean;
|
||||
}
|
||||
|
||||
function MentionEveryoneRow({
|
||||
isSelected,
|
||||
selectedRef,
|
||||
onSelect,
|
||||
mobile,
|
||||
}: {
|
||||
isSelected: boolean;
|
||||
selectedRef: React.RefObject<HTMLDivElement>;
|
||||
onSelect: (entry: MentionEntry) => void;
|
||||
mobile: boolean;
|
||||
}) {
|
||||
const t = useT();
|
||||
const rowSizing = mobile ? 'gap-3 px-3 py-2.5 min-h-[44px]' : 'gap-2.5 px-2 py-1.5';
|
||||
return (
|
||||
<div
|
||||
ref={isSelected ? selectedRef : undefined}
|
||||
onClick={() => onSelect({ kind: 'everyone' })}
|
||||
className={`flex items-center mx-1 rounded cursor-pointer transition-colors ${rowSizing} ${
|
||||
isSelected ? 'bg-interactive-selected' : 'hover:bg-interactive-hover'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`${mobile ? 'w-7 h-7' : 'w-6 h-6'} rounded-full flex items-center justify-center flex-shrink-0 text-[13px] font-bold`}
|
||||
style={{ color: '#fbbf24', backgroundColor: '#fbbf241a' }}
|
||||
>
|
||||
@
|
||||
</div>
|
||||
<span
|
||||
className={`${mobile ? 'text-[15px]' : 'text-[14px]'} font-medium`}
|
||||
style={{ color: '#fbbf24' }}
|
||||
>
|
||||
{t('mention.everyone.label')}
|
||||
</span>
|
||||
<span className="text-[12px] text-txt-tertiary truncate">
|
||||
{t('mention.everyone.description')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ResolvedListProps {
|
||||
filtered: MemberWithUser[];
|
||||
selectedIndex: number;
|
||||
selectedRef: React.RefObject<HTMLDivElement>;
|
||||
onSelect: (member: MemberWithUser) => void;
|
||||
onSelect: (entry: MentionEntry) => void;
|
||||
getMemberColor: (member: MemberWithUser) => string | undefined;
|
||||
mobile: boolean;
|
||||
showEveryone: boolean;
|
||||
}
|
||||
|
||||
function MemberList({
|
||||
@@ -84,19 +137,39 @@ function MemberList({
|
||||
onSelect,
|
||||
getMemberColor,
|
||||
mobile,
|
||||
showEveryone,
|
||||
}: ResolvedListProps) {
|
||||
const t = useT();
|
||||
// @everyone occupies index 0 when shown, so member rows shift down by one.
|
||||
// The composer clamps its keyboard selection against the same offset.
|
||||
const memberOffset = showEveryone ? 1 : 0;
|
||||
return (
|
||||
<>
|
||||
<div className="px-2 py-1.5 text-[11px] font-bold text-txt-tertiary uppercase tracking-wider">
|
||||
Members
|
||||
</div>
|
||||
{showEveryone && (
|
||||
<>
|
||||
<div className="px-2 py-1.5 text-[11px] font-bold text-txt-tertiary uppercase tracking-wider">
|
||||
{t('mention.everyone.section')}
|
||||
</div>
|
||||
<MentionEveryoneRow
|
||||
isSelected={selectedIndex === 0}
|
||||
selectedRef={selectedRef}
|
||||
onSelect={onSelect}
|
||||
mobile={mobile}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{filtered.length > 0 && (
|
||||
<div className="px-2 py-1.5 text-[11px] font-bold text-txt-tertiary uppercase tracking-wider">
|
||||
{t('mention.members')}
|
||||
</div>
|
||||
)}
|
||||
{filtered.map((member, i) => (
|
||||
<MentionMemberRow
|
||||
key={member.userId}
|
||||
member={member}
|
||||
isSelected={i === selectedIndex}
|
||||
isSelected={i + memberOffset === selectedIndex}
|
||||
selectedRef={selectedRef}
|
||||
onSelect={onSelect}
|
||||
onSelect={(m) => onSelect({ kind: 'member', member: m })}
|
||||
roleColor={getMemberColor(member)}
|
||||
mobile={mobile}
|
||||
/>
|
||||
@@ -116,6 +189,7 @@ function DesktopMention({
|
||||
onSelect,
|
||||
anchorRef,
|
||||
getMemberColor,
|
||||
showEveryone,
|
||||
}: DesktopMentionProps) {
|
||||
const selectedRef = useRef<HTMLDivElement>(null);
|
||||
const floatingRef = useRef<HTMLDivElement>(null);
|
||||
@@ -123,7 +197,7 @@ function DesktopMention({
|
||||
const { style } = useFloatingPosition(anchorRef, floatingRef, {
|
||||
placement: 'top',
|
||||
offset: 4,
|
||||
enabled: filtered.length > 0,
|
||||
enabled: filtered.length > 0 || showEveryone,
|
||||
});
|
||||
|
||||
// Scroll selected item into view
|
||||
@@ -141,6 +215,7 @@ function DesktopMention({
|
||||
onSelect={onSelect}
|
||||
getMemberColor={getMemberColor}
|
||||
mobile={false}
|
||||
showEveryone={showEveryone}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
@@ -158,6 +233,7 @@ function MobileMention({
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
getMemberColor,
|
||||
showEveryone,
|
||||
}: MobileMentionProps) {
|
||||
const selectedRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -193,6 +269,7 @@ function MobileMention({
|
||||
onSelect={onSelect}
|
||||
getMemberColor={getMemberColor}
|
||||
mobile={true}
|
||||
showEveryone={showEveryone}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -201,7 +278,7 @@ function MobileMention({
|
||||
);
|
||||
}
|
||||
|
||||
export function MentionPopover({ query, selectedIndex, onSelect, anchorRef }: MentionPopoverProps) {
|
||||
export function MentionPopover({ query, selectedIndex, onSelect, anchorRef, showEveryone }: MentionPopoverProps) {
|
||||
const members = useSpaceStore((s) => s.members);
|
||||
const spaces = useSpaceStore((s) => s.spaces);
|
||||
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||
@@ -220,7 +297,7 @@ export function MentionPopover({ query, selectedIndex, onSelect, anchorRef }: Me
|
||||
.slice(0, MAX_RESULTS);
|
||||
}, [members, query]);
|
||||
|
||||
if (filtered.length === 0) return null;
|
||||
if (filtered.length === 0 && !showEveryone) return null;
|
||||
|
||||
const getMemberColor = (member: MemberWithUser): string | undefined => {
|
||||
if (member.roles && member.roles.length > 0) {
|
||||
@@ -240,6 +317,7 @@ export function MentionPopover({ query, selectedIndex, onSelect, anchorRef }: Me
|
||||
anchorRef={anchorRef}
|
||||
filtered={filtered}
|
||||
getMemberColor={getMemberColor}
|
||||
showEveryone={showEveryone}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -252,6 +330,7 @@ export function MentionPopover({ query, selectedIndex, onSelect, anchorRef }: Me
|
||||
anchorRef={anchorRef}
|
||||
filtered={filtered}
|
||||
getMemberColor={getMemberColor}
|
||||
showEveryone={showEveryone}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useExpressionStore } from '../../stores/expressionStore';
|
||||
import { renderCustomEmojis } from '../../utils/customEmoji';
|
||||
import { api } from '../../api/client';
|
||||
import { useT } from '../../i18n';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { MessageWithUser, Embed, User } from '@backspace/shared';
|
||||
import { MarkdownRenderer } from './MarkdownRenderer';
|
||||
@@ -27,6 +31,7 @@ import {
|
||||
type PendingAttachmentView,
|
||||
} from '../../stores/pendingMessageStore';
|
||||
import { useTransferStore } from '../../stores/transferStore';
|
||||
import { mentionsEveryone } from '@backspace/shared/src/mentions';
|
||||
|
||||
interface MessageProps {
|
||||
message: MessageWithUser | PendingMessageView;
|
||||
@@ -131,6 +136,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
|
||||
const editMessage = useChatStore((s) => s.editMessage);
|
||||
const deleteMessage = useChatStore((s) => s.deleteMessage);
|
||||
const members = useSpaceStore((s) => s.members);
|
||||
const tr = useT();
|
||||
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
||||
|
||||
const pending = isPendingMessage(message) ? message.__pending : null;
|
||||
@@ -230,6 +236,26 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
|
||||
return () => clearTimeout(confirmDeleteTimeout.current);
|
||||
}, []);
|
||||
|
||||
// Emojis próprios do espaço são resolvidos antes do markdown; sem espaço
|
||||
// (DM, por exemplo) o texto passa intacto.
|
||||
const emojiSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||
const emojiLookup = useExpressionStore((s) => s.emojiByName);
|
||||
const contentWithEmojis = useMemo(() => {
|
||||
if (!message.content || !emojiSpaceId) return message.content ?? '';
|
||||
return renderCustomEmojis(
|
||||
message.content,
|
||||
(name) => emojiLookup(emojiSpaceId, name),
|
||||
(filename) => api.uploads.url(filename),
|
||||
);
|
||||
}, [message.content, emojiSpaceId, emojiLookup]);
|
||||
|
||||
const stickerId = (message as MessageWithUser).stickerId;
|
||||
const sticker = useExpressionStore((s) =>
|
||||
stickerId && emojiSpaceId
|
||||
? s.stickersBySpace.get(emojiSpaceId)?.find((k) => k.id === stickerId)
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const isGifOnly = isGifOnlyMessage(message.content);
|
||||
const imageEmbedSourceUrl = isGifOnly ? null : getImageEmbedSourceUrl(message.content, message.embeds || []);
|
||||
// sourceUrl: the original URL for context menu Copy/Open Link actions
|
||||
@@ -329,6 +355,15 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
|
||||
setEditContent(message.content ?? '');
|
||||
setIsEditing(true);
|
||||
},
|
||||
isPinned: Boolean((message as MessageWithUser).pinnedAt),
|
||||
labels: { pin: tr('pins.pin'), unpin: tr('pins.unpin') },
|
||||
onTogglePin: () => {
|
||||
const pinned = Boolean((message as MessageWithUser).pinnedAt);
|
||||
// Sem atualização otimista: o servidor recusa acima do limite do canal,
|
||||
// e mostrar como fixada antes da confirmação mentiria nesse caso.
|
||||
void (pinned ? api.pins.unpin(message.id) : api.pins.pin(message.id))
|
||||
.catch(() => { /* o servidor rejeitou; o estado permanece o que era */ });
|
||||
},
|
||||
onDelete: () => deleteMessage(message.id, channelKey),
|
||||
onReaction: (emoji: string) => toggleReaction(emoji),
|
||||
onOpenEmojiPicker: () => {
|
||||
@@ -385,8 +420,12 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
|
||||
|
||||
const replyRoleColor = (msg: { userId: string }) => getMemberDisplayColor(msg.userId);
|
||||
|
||||
// Self-mention highlighting
|
||||
const isMentioned = currentUser && message.content?.includes('<@' + currentUser.id + '>');
|
||||
// Mention highlighting — the same amber row for a direct ping and for
|
||||
// @everyone, because to the reader both mean "this one is for you".
|
||||
const isMentioned = Boolean(
|
||||
(currentUser && message.content?.includes('<@' + currentUser.id + '>')) ||
|
||||
mentionsEveryone(message.content),
|
||||
);
|
||||
|
||||
const content = (
|
||||
<div
|
||||
@@ -516,7 +555,19 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
|
||||
<>
|
||||
{message.content && (
|
||||
<div className="text-txt-message text-[15px] leading-[1.5] break-words whitespace-pre-wrap selection:bg-accent-primary/30">
|
||||
<MarkdownRenderer content={message.content} />
|
||||
{sticker ? (
|
||||
// Figurinha ocupa a mensagem inteira, sem moldura de
|
||||
// anexo: é o conteúdo, não um arquivo acompanhando texto.
|
||||
<img
|
||||
src={api.uploads.url(sticker.filename)}
|
||||
alt={sticker.name}
|
||||
title={sticker.name}
|
||||
className="w-[160px] h-[160px] object-contain rounded-lg"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<MarkdownRenderer content={contentWithEmojis} />
|
||||
)}
|
||||
{message.editedAt && (
|
||||
<span className="text-[10px] text-txt-tertiary ml-1 select-none font-medium">(edited)</span>
|
||||
)}
|
||||
@@ -676,7 +727,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
|
||||
className={`p-1 hover:bg-interactive-hover rounded transition-colors text-[14px] leading-none ${
|
||||
showReactionPicker ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
|
||||
}`}
|
||||
title="Add reaction"
|
||||
title={tr('chat.message.addReaction')}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm1-13h-2v4H7v2h4v4h2v-4h4v-2h-4V7z" />
|
||||
@@ -687,7 +738,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
|
||||
<button
|
||||
onClick={() => setReplyTo(message)}
|
||||
className="px-2 h-full text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover transition-all flex items-center justify-center"
|
||||
title="Reply"
|
||||
title={tr('chat.message.reply')}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M10 9V5L3 12L10 19V14.9C15 14.9 18.5 16.5 21 20C20 15 17 10 10 9Z" />
|
||||
@@ -700,7 +751,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
|
||||
setIsEditing(true);
|
||||
}}
|
||||
className="px-2 h-full text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover transition-all flex items-center justify-center"
|
||||
title="Edit"
|
||||
title={tr('chat.message.edit')}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import React, { useState, useRef, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useT } from '../../i18n';
|
||||
import { useChatStore } from '../../stores/chatStore';
|
||||
import { isDmChannel, getChannelOrigin, useSpaceStore } from '../../stores/spaceStore';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
import { MentionPopover } from './MentionPopover';
|
||||
import { MentionPopover, type MentionEntry } from './MentionPopover';
|
||||
import { TypingIndicator } from './TypingIndicator';
|
||||
import { InputPopover, type InputPopoverTab } from './InputPopover';
|
||||
import { AttachmentProgress } from './AttachmentProgress';
|
||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||
import { EVERYONE_MENTION_ID, EVERYONE_MENTION_TOKEN } from '@backspace/shared/src/mentions';
|
||||
import { MAX_MESSAGE_LENGTH, type MemberWithUser } from '@backspace/shared';
|
||||
import { useSettingsStore } from '../../stores/settingsStore';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
@@ -94,6 +96,17 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
||||
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
// Feature flags
|
||||
const tr = useT();
|
||||
// Figurinhas pertencem a um servidor; em DM não há o que oferecer.
|
||||
const stickerSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||
|
||||
const handleStickerSelect = (stickerId: string) => {
|
||||
setActivePopover(null);
|
||||
// Enviada de imediato: a figurinha é a mensagem inteira, então não faz
|
||||
// sentido acumulá-la no campo de texto esperando um Enter.
|
||||
void sendMessage(channelId, '', undefined, stickerId);
|
||||
};
|
||||
|
||||
const gifEnabled = useSettingsStore((s) => s.gifEnabled);
|
||||
|
||||
// Permission gating: DM channels always allow sending; space channels check SEND_MESSAGES
|
||||
@@ -101,6 +114,9 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
||||
const isDm = isDmChannel(channelId);
|
||||
const canSendMessages = isDm || hasPermissionBit(channelPerms, PermissionBits.SEND_MESSAGES);
|
||||
const canAttachFiles = isDm || hasPermissionBit(channelPerms, PermissionBits.ATTACH_FILES);
|
||||
// A DM has no roles to gate on, and pinging "everyone" in a two-person chat
|
||||
// is noise, so the row is offered in space channels only.
|
||||
const canMentionEveryone = !isDm && hasPermissionBit(channelPerms, PermissionBits.MENTION_EVERYONE);
|
||||
|
||||
// Derive staged transfers from composerStore staged ids + transferStore map
|
||||
const stagedTransfers: Transfer[] = useMemo(() => {
|
||||
@@ -195,6 +211,17 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
||||
.slice(0, 8);
|
||||
}, [members, mentionState]);
|
||||
|
||||
// The @everyone row is offered while the typed query is still a prefix of the
|
||||
// word — same rule the member filter uses, so the two lists appear and vanish
|
||||
// together rather than one lingering.
|
||||
const showEveryone =
|
||||
!!mentionState &&
|
||||
canMentionEveryone &&
|
||||
EVERYONE_MENTION_ID.startsWith(mentionState.query.toLowerCase());
|
||||
|
||||
// Row count for keyboard clamping: @everyone occupies index 0 when shown.
|
||||
const mentionRowCount = filteredMembers.length + (showEveryone ? 1 : 0);
|
||||
|
||||
const handleTyping = useCallback(() => {
|
||||
if (typingTimeoutRef.current) return;
|
||||
const dm = isDmChannel(channelId);
|
||||
@@ -374,13 +401,14 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
||||
};
|
||||
|
||||
const selectMention = useCallback(
|
||||
(member: MemberWithUser) => {
|
||||
(entry: MentionEntry) => {
|
||||
if (!mentionState) return;
|
||||
const textarea = textareaRef.current;
|
||||
const cursorPos = textarea?.selectionStart ?? draftText.length;
|
||||
const before = draftText.slice(0, mentionState.startIndex);
|
||||
const after = draftText.slice(cursorPos);
|
||||
const insertion = `<@${member.userId}> `;
|
||||
const insertion =
|
||||
entry.kind === 'everyone' ? `${EVERYONE_MENTION_TOKEN} ` : `<@${entry.member.userId}> `;
|
||||
const newContent = before + insertion + after;
|
||||
setDraft(channelId, newContent);
|
||||
setMentionState(null);
|
||||
@@ -400,11 +428,11 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent): void => {
|
||||
// Mention popover keyboard navigation
|
||||
if (mentionState && filteredMembers.length > 0) {
|
||||
if (mentionState && mentionRowCount > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setMentionState((prev) =>
|
||||
prev ? { ...prev, selectedIndex: Math.min(prev.selectedIndex + 1, filteredMembers.length - 1) } : null,
|
||||
prev ? { ...prev, selectedIndex: Math.min(prev.selectedIndex + 1, mentionRowCount - 1) } : null,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -417,8 +445,12 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const selected = filteredMembers[mentionState.selectedIndex];
|
||||
if (selected) selectMention(selected);
|
||||
if (showEveryone && mentionState.selectedIndex === 0) {
|
||||
selectMention({ kind: 'everyone' });
|
||||
return;
|
||||
}
|
||||
const selected = filteredMembers[mentionState.selectedIndex - (showEveryone ? 1 : 0)];
|
||||
if (selected) selectMention({ kind: 'member', member: selected });
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
@@ -529,21 +561,25 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
||||
};
|
||||
|
||||
const handleEmojiSelect = useCallback(
|
||||
(emoji: { native: string }) => {
|
||||
(emoji: { native?: string; id?: string }) => {
|
||||
// Emoji próprio não tem `native`: entra no texto como `:nome:`, que é
|
||||
// o que o render resolve depois para a imagem.
|
||||
const inserted = emoji.native ?? (emoji.id ? `:${emoji.id}:` : '');
|
||||
if (!inserted) return;
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) {
|
||||
setDraft(channelId, draftText + emoji.native);
|
||||
setDraft(channelId, draftText + inserted);
|
||||
return;
|
||||
}
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const before = draftText.slice(0, start);
|
||||
const after = draftText.slice(end);
|
||||
const newContent = before + emoji.native + after;
|
||||
const newContent = before + inserted + after;
|
||||
setDraft(channelId, newContent);
|
||||
|
||||
// Restore cursor position after the emoji
|
||||
const newCursorPos = start + emoji.native.length;
|
||||
const newCursorPos = start + inserted.length;
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus();
|
||||
textarea.selectionStart = newCursorPos;
|
||||
@@ -766,6 +802,8 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
||||
onClose={() => setActivePopover(null)}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
onGifSelect={handleGifSelect}
|
||||
onStickerSelect={handleStickerSelect}
|
||||
hasStickers={Boolean(stickerSpaceId)}
|
||||
anchorRef={popoverAnchorRef}
|
||||
gifEnabled={gifEnabled}
|
||||
onTabChange={setActivePopover}
|
||||
@@ -775,7 +813,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
||||
{chatReplyTo && (
|
||||
<div className="bg-interactive-hover rounded-t-lg px-4 py-2 flex items-center justify-between border-b border-white/[0.06]">
|
||||
<div className="flex items-center gap-1 text-[14px] text-txt-message truncate">
|
||||
<span className="opacity-60">Replying to</span>
|
||||
<span className="opacity-60">{tr('chat.composer.replyingTo')}</span>
|
||||
<span className="font-bold">
|
||||
{chatReplyTo.user.displayName ?? chatReplyTo.user.username}
|
||||
</span>
|
||||
@@ -783,7 +821,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
||||
<button
|
||||
onClick={() => chatSetReplyTo(null)}
|
||||
className="text-txt-tertiary hover:text-txt-primary transition-colors"
|
||||
aria-label="Cancel reply"
|
||||
aria-label={tr('chat.composer.cancelReply')}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
|
||||
@@ -804,6 +842,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
||||
selectedIndex={mentionState.selectedIndex}
|
||||
onSelect={selectMention}
|
||||
anchorRef={inputContainerRef}
|
||||
showEveryone={showEveryone}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -867,7 +906,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
||||
<button
|
||||
onClick={() => removeStagedTransfer(t.id)}
|
||||
className="absolute -top-2 -right-2 w-7 h-7 bg-accent-rose hover:bg-accent-rose/80 shadow-elevation-high rounded-lg flex items-center justify-center text-white transition-colors z-10"
|
||||
aria-label="Remove attachment"
|
||||
aria-label={tr('chat.composer.removeAttachment')}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M5 2a1 1 0 011-1h4a1 1 0 011 1v1h3a1 1 0 110 2h-.08L13 14a2 2 0 01-2 2H5a2 2 0 01-2-2L2.08 5H2a1 1 0 110-2h3V2zm2 0v1h2V2H7z" />
|
||||
@@ -886,8 +925,8 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] text-txt-tertiary hover:text-txt-secondary transition-colors flex-shrink-0"
|
||||
title="Attach file"
|
||||
aria-label="Attach file"
|
||||
title={tr('chat.composer.attach')}
|
||||
aria-label={tr('chat.composer.attach')}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" />
|
||||
@@ -915,14 +954,16 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={canAttachFiles ? handlePaste : undefined}
|
||||
placeholder={placeholder ?? `Message ${channelName.startsWith('@') ? channelName : `#${channelName}`}`}
|
||||
placeholder={placeholder ?? tr('chat.composer.placeholder', {
|
||||
channel: channelName.startsWith('@') ? channelName : `#${channelName}`,
|
||||
})}
|
||||
className="input-embedded flex-1 py-[10px] px-1 resize-none text-[15px] leading-[1.375rem] max-h-[50vh] scrollbar-thin"
|
||||
rows={1}
|
||||
/>
|
||||
|
||||
{/* Active-upload indicator */}
|
||||
{anyActiveOrQueued && (
|
||||
<div className="p-3 text-txt-tertiary" title="Uploading…" aria-label="Uploading">
|
||||
<div className="p-3 text-txt-tertiary" title={tr('chat.composer.uploadingEllipsis')} aria-label={tr('chat.composer.uploading')}>
|
||||
<svg className="w-5 h-5 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
@@ -934,7 +975,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
||||
{failedCount > 0 && (
|
||||
<span
|
||||
className="text-[12px] font-medium text-accent-rose px-1 flex-shrink-0"
|
||||
title="Remove or retry the failed attachment to send"
|
||||
title={tr('chat.composer.failedAttachment')}
|
||||
>
|
||||
{failedCount} failed
|
||||
</span>
|
||||
@@ -956,8 +997,8 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
||||
className={`w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] transition-colors flex-shrink-0 ${
|
||||
activePopover === 'gif' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
|
||||
}`}
|
||||
title="GIF"
|
||||
aria-label="GIF picker"
|
||||
title={tr('chat.composer.gif')}
|
||||
aria-label={tr('chat.composer.gifPicker')}
|
||||
>
|
||||
{/* Outlined badge, not a filled block: the solid rectangle read as
|
||||
a plain square rather than a GIF picker. Letters reuse the
|
||||
@@ -977,8 +1018,8 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
||||
className={`w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] transition-colors flex-shrink-0 ${
|
||||
activePopover === 'emoji' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
|
||||
}`}
|
||||
title="Emoji"
|
||||
aria-label="Emoji picker"
|
||||
title={tr('chat.composer.emoji')}
|
||||
aria-label={tr('chat.composer.emojiPicker')}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5s.67 1.5 1.5 1.5zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" />
|
||||
@@ -991,8 +1032,8 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={anyUnshippable}
|
||||
className="w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] bg-accent-primary hover:bg-accent-primary-hover text-white transition-all duration-150 flex-shrink-0 disabled:opacity-50"
|
||||
aria-label="Send message"
|
||||
title="Send"
|
||||
aria-label={tr('chat.composer.sendMessage')}
|
||||
title={tr('chat.composer.send')}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3.4 20.4l17.45-7.48a1 1 0 000-1.84L3.4 3.6a.993.993 0 00-1.39.91L2 9.12c0 .5.37.93.87.99L17 12 2.87 13.88c-.5.07-.87.5-.87 1l.01 4.61c0 .71.73 1.2 1.39.91z" />
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { MessageWithUser } from '@backspace/shared';
|
||||
import { api } from '../../api/client';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { useT } from '../../i18n';
|
||||
|
||||
interface PinsPopoverProps {
|
||||
channelId: string;
|
||||
onClose: () => void;
|
||||
onJumpToMessage: (messageId: string) => void;
|
||||
}
|
||||
|
||||
export function PinsPopover({ channelId, onClose, onJumpToMessage }: PinsPopoverProps) {
|
||||
const t = useT();
|
||||
const [messages, setMessages] = useState<MessageWithUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
api.pins.list(channelId)
|
||||
.then(({ messages: list }) => { if (!cancelled) setMessages(list); })
|
||||
.catch(() => { if (!cancelled) setMessages([]); })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [channelId]);
|
||||
|
||||
useEffect(() => {
|
||||
const onPointer = (e: MouseEvent | TouchEvent) => {
|
||||
if (!panelRef.current?.contains(e.target as Node)) onClose();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
// touchstart junto de mousedown: o iOS Safari não sintetiza mousedown de
|
||||
// toque de forma confiável, como os outros popovers deste projeto tratam.
|
||||
document.addEventListener('mousedown', onPointer);
|
||||
document.addEventListener('touchstart', onPointer);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onPointer);
|
||||
document.removeEventListener('touchstart', onPointer);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="absolute right-0 top-full mt-2 z-[300] w-[380px] max-h-[460px] glass rounded-xl overflow-hidden flex flex-col shadow-xl"
|
||||
>
|
||||
<div className="px-4 py-3 border-b border-border-soft shrink-0">
|
||||
<span className="text-[13px] font-semibold text-txt-primary">{t('pins.title')}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin p-2">
|
||||
{loading ? (
|
||||
<div className="space-y-2 p-1">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-14 rounded-lg bg-surface-elevated animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<p className="text-[12px] text-txt-tertiary p-3">{t('pins.empty')}</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{messages.map((m) => (
|
||||
<li key={m.id}>
|
||||
<button
|
||||
onClick={() => { onJumpToMessage(m.id); onClose(); }}
|
||||
className="w-full text-left flex gap-2.5 p-2 rounded-lg hover:bg-interactive-hover transition-colors"
|
||||
>
|
||||
<Avatar
|
||||
src={m.user.avatar}
|
||||
name={m.user.displayName ?? m.user.username}
|
||||
size={28}
|
||||
userId={m.user.id}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[12px] font-semibold text-txt-primary truncate">
|
||||
{m.user.displayName ?? m.user.username}
|
||||
</div>
|
||||
<div className="text-[12px] text-txt-secondary line-clamp-2 break-words">
|
||||
{m.content || ''}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useT } from '../../i18n';
|
||||
import { parseSearchQuery } from '../../utils/searchQuery';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
|
||||
import { isDmChannel, getChannelOrigin, getApiForOrigin } from '../../stores/spaceStore';
|
||||
@@ -103,6 +105,7 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
|
||||
});
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const t = useT();
|
||||
const [fromFilter, setFromFilter] = useState('');
|
||||
const [hasFilter, setHasFilter] = useState('');
|
||||
const [beforeFilter, setBeforeFilter] = useState('');
|
||||
@@ -153,8 +156,16 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
|
||||
}, [open, onClose]);
|
||||
|
||||
const doSearch = useCallback(async (searchOffset = 0) => {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed && !fromFilter && !hasFilter && !beforeFilter && !afterFilter) {
|
||||
// Filtros digitados na consulta (`de:fulano`) valem sobre os do painel:
|
||||
// quem acabou de escrever está expressando a intenção mais recente.
|
||||
const parsed = parseSearchQuery(query);
|
||||
const trimmed = parsed.text;
|
||||
const effFrom = parsed.from ?? fromFilter;
|
||||
const effHas = parsed.has ?? hasFilter;
|
||||
const effBefore = parsed.before ?? beforeFilter;
|
||||
const effAfter = parsed.after ?? afterFilter;
|
||||
|
||||
if (!trimmed && !effFrom && !effHas && !effBefore && !effAfter) {
|
||||
setResults([]);
|
||||
setTotalCount(0);
|
||||
return;
|
||||
@@ -166,10 +177,10 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
|
||||
const client = getApiForOrigin(origin);
|
||||
const params = {
|
||||
q: trimmed || undefined,
|
||||
from: fromFilter || undefined,
|
||||
has: hasFilter || undefined,
|
||||
before: beforeFilter || undefined,
|
||||
after: afterFilter || undefined,
|
||||
from: effFrom || undefined,
|
||||
has: effHas || undefined,
|
||||
before: effBefore || undefined,
|
||||
after: effAfter || undefined,
|
||||
offset: searchOffset,
|
||||
limit: 25,
|
||||
};
|
||||
@@ -224,7 +235,7 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search messages..."
|
||||
placeholder={t('search.placeholder')}
|
||||
className="input-embedded flex-1 text-[14px]"
|
||||
/>
|
||||
{query && (
|
||||
@@ -257,30 +268,30 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
|
||||
{showFilters && (
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">From</label>
|
||||
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">{t('search.from')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={fromFilter}
|
||||
onChange={(e) => setFromFilter(e.target.value)}
|
||||
placeholder="username"
|
||||
placeholder={t('search.fromPlaceholder')}
|
||||
className="input-search w-full px-2 py-1 text-[13px]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">Has</label>
|
||||
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">{t('search.has')}</label>
|
||||
<select
|
||||
value={hasFilter}
|
||||
onChange={(e) => setHasFilter(e.target.value)}
|
||||
className="input-search w-full px-2 py-1 text-[13px] appearance-none cursor-pointer"
|
||||
>
|
||||
<option value="">Any</option>
|
||||
<option value="file">File</option>
|
||||
<option value="image">Image</option>
|
||||
<option value="link">Link</option>
|
||||
<option value="">{t('search.hasAny')}</option>
|
||||
<option value="file">{t('search.hasFile')}</option>
|
||||
<option value="image">{t('search.hasImage')}</option>
|
||||
<option value="link">{t('search.hasLink')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">Before</label>
|
||||
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">{t('search.before')}</label>
|
||||
<input
|
||||
type="date"
|
||||
value={beforeFilter}
|
||||
@@ -289,7 +300,7 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">After</label>
|
||||
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">{t('search.after')}</label>
|
||||
<input
|
||||
type="date"
|
||||
value={afterFilter}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import { useExpressionStore } from '../../stores/expressionStore';
|
||||
import { useSpaceStore } from '../../stores/spaceStore';
|
||||
import { useT } from '../../i18n';
|
||||
|
||||
interface StickerPickerProps {
|
||||
onStickerSelect: (stickerId: string) => void;
|
||||
mobile?: boolean;
|
||||
}
|
||||
|
||||
export function StickerPicker({ onStickerSelect, mobile = false }: StickerPickerProps) {
|
||||
const t = useT();
|
||||
const spaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||
const stickers = useExpressionStore((s) => (spaceId ? s.stickersBySpace.get(spaceId) : undefined)) ?? [];
|
||||
const load = useExpressionStore((s) => s.load);
|
||||
|
||||
useEffect(() => {
|
||||
if (spaceId) void load(spaceId);
|
||||
}, [spaceId, load]);
|
||||
|
||||
// Mesmas dimensões do seletor de GIF, para as abas do popover não pularem de
|
||||
// tamanho ao alternar.
|
||||
const rootClass = mobile
|
||||
? 'flex flex-col flex-1 min-h-0 w-full'
|
||||
: 'flex flex-col h-[390px] w-[390px]';
|
||||
|
||||
return (
|
||||
<div className={rootClass}>
|
||||
<div className="px-3 pt-3 pb-2 shrink-0">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-txt-tertiary">
|
||||
{t('expressions.pickerTitle')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin px-2 pb-2">
|
||||
{stickers.length === 0 ? (
|
||||
<p className="text-[12px] text-txt-tertiary p-3">{t('expressions.pickerEmpty')}</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{stickers.map((sticker) => (
|
||||
<button
|
||||
key={sticker.id}
|
||||
type="button"
|
||||
onClick={() => onStickerSelect(sticker.id)}
|
||||
title={sticker.name}
|
||||
className="aspect-square rounded-lg bg-surface-elevated hover:brightness-125 transition-all flex items-center justify-center p-2"
|
||||
>
|
||||
<img
|
||||
src={api.uploads.url(sticker.filename)}
|
||||
alt={sticker.name}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -96,3 +96,26 @@ describe('SystemMessage — icon_changed', () => {
|
||||
expect(screen.getByText(/Heidi updated the group icon/)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SystemMessage — message_pinned (space channel, no roster)', () => {
|
||||
it('names the actor from message.user and offers both links', async () => {
|
||||
const { useUIStore } = await import('../../stores/uiStore');
|
||||
const msg = buildMessage({ event: 'message_pinned', messageId: 'M9' });
|
||||
renderSM(msg, null);
|
||||
|
||||
expect(screen.getByText(/Heidi pinned/)).toBeDefined();
|
||||
|
||||
const before = useUIStore.getState().pinsPanelRequest;
|
||||
screen.getByText('a message').click();
|
||||
expect(useUIStore.getState().jumpToMessageRequest).toBe('M9');
|
||||
|
||||
screen.getByText('See all pinned messages.').click();
|
||||
expect(useUIStore.getState().pinsPanelRequest).toBe(before + 1);
|
||||
});
|
||||
|
||||
it('falls back to the plain renderer when messageId is missing', () => {
|
||||
// A malformed payload must not throw — it degrades to the generic branch.
|
||||
const msg = buildMessage({ event: 'message_pinned' });
|
||||
expect(() => renderSM(msg, null)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { DmChannel, MessageWithUser, SpaceInviteSystemPayload, User } from '@backspace/shared';
|
||||
import { SpaceInviteCard } from './SpaceInviteCard';
|
||||
import { useT } from '../../i18n';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
|
||||
interface SystemMessageProps {
|
||||
message: MessageWithUser;
|
||||
@@ -13,15 +15,20 @@ interface SystemMessageProps {
|
||||
dm?: Pick<DmChannel, 'members'> | null;
|
||||
}
|
||||
|
||||
function resolveActorName(message: MessageWithUser, dm?: Pick<DmChannel, 'members'> | null): string {
|
||||
function resolveActorName(
|
||||
message: MessageWithUser,
|
||||
dm: Pick<DmChannel, 'members'> | null | undefined,
|
||||
unknown: string,
|
||||
someone: string,
|
||||
): string {
|
||||
if (dm) {
|
||||
const fromRoster = dm.members.find(m => m.id === message.userId) as User | undefined;
|
||||
if (fromRoster) {
|
||||
return fromRoster.displayName ?? fromRoster.username ?? 'Unknown';
|
||||
return fromRoster.displayName ?? fromRoster.username ?? unknown;
|
||||
}
|
||||
return 'Unknown';
|
||||
return unknown;
|
||||
}
|
||||
return message.user?.displayName ?? message.user?.username ?? 'Someone';
|
||||
return message.user?.displayName ?? message.user?.username ?? someone;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,10 +39,11 @@ function resolveActorName(message: MessageWithUser, dm?: Pick<DmChannel, 'member
|
||||
* Exported so unit tests can render it directly without mounting MessageList.
|
||||
*/
|
||||
export function SystemMessage({ message, dm }: SystemMessageProps) {
|
||||
const t = useT();
|
||||
let data: Record<string, unknown> = {};
|
||||
try { data = JSON.parse(message.content ?? '{}'); } catch { /* fall through to default branch */ }
|
||||
|
||||
const actorName = resolveActorName(message, dm);
|
||||
const actorName = resolveActorName(message, dm, t('system.unknown'), t('system.someone'));
|
||||
|
||||
if (data.event === 'space_invite') {
|
||||
return (
|
||||
@@ -45,38 +53,65 @@ export function SystemMessage({ message, dm }: SystemMessageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// The pin notice has two links in one sentence — the pinned message, and the
|
||||
// pins panel — so it is assembled from parts around the placeholders rather
|
||||
// than as flat text. Word order stays with the translation.
|
||||
if (data.event === 'message_pinned' && typeof data.messageId === 'string') {
|
||||
const pinnedId = data.messageId;
|
||||
const parts = t('system.pinned', { actor: actorName, message: '\u0000', seeAll: '\u0001' });
|
||||
const [beforeMsg, rest = ''] = parts.split('\u0000');
|
||||
const [between, after = ''] = rest.split('\u0001');
|
||||
const linkClass = 'text-txt-secondary hover:text-txt-primary hover:underline cursor-pointer';
|
||||
return (
|
||||
<div className="flex items-center justify-center py-1 px-4 select-none">
|
||||
<span className="text-xs text-txt-tertiary">
|
||||
<span className="mr-1.5">📌</span>
|
||||
{beforeMsg}
|
||||
<span className={linkClass} onClick={() => useUIStore.getState().requestJumpToMessage(pinnedId)}>
|
||||
{t('system.pinned.message')}
|
||||
</span>
|
||||
{between}
|
||||
<span className={linkClass} onClick={() => useUIStore.getState().requestPinsPanel()}>
|
||||
{t('system.pinned.seeAll')}
|
||||
</span>
|
||||
{after}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline-text events.
|
||||
let text = '';
|
||||
let icon = '';
|
||||
switch (data.event) {
|
||||
case 'member_added':
|
||||
icon = '→'; // →
|
||||
text = `${actorName} added ${data.targetDisplayName} to the group`;
|
||||
text = t('system.memberAdded', { actor: actorName, target: String(data.targetDisplayName) });
|
||||
break;
|
||||
case 'member_removed':
|
||||
if (data.reason === 'leave') {
|
||||
icon = '←'; // ←
|
||||
text = `${data.targetDisplayName} left the group`;
|
||||
text = t('system.memberLeft', { target: String(data.targetDisplayName) });
|
||||
} else {
|
||||
icon = '←';
|
||||
text = `${actorName} removed ${data.targetDisplayName} from the group`;
|
||||
text = t('system.memberRemoved', { actor: actorName, target: String(data.targetDisplayName) });
|
||||
}
|
||||
break;
|
||||
case 'owner_changed':
|
||||
icon = '♛'; // ♛
|
||||
text = `${data.newOwnerDisplayName} is now the group owner`;
|
||||
text = t('system.ownerChanged', { owner: String(data.newOwnerDisplayName) });
|
||||
break;
|
||||
case 'name_changed':
|
||||
icon = '✎'; // ✎
|
||||
// newName === null is a meaningful "cleared" state — distinct from a
|
||||
// missing field — so we test for a non-empty string explicitly.
|
||||
text = typeof data.newName === 'string' && data.newName.length > 0
|
||||
? `${actorName} renamed the group to "${data.newName}"`
|
||||
: `${actorName} cleared the group name`;
|
||||
? t('system.nameChanged', { actor: actorName, name: data.newName })
|
||||
: t('system.nameCleared', { actor: actorName });
|
||||
break;
|
||||
case 'icon_changed':
|
||||
icon = '\u{1F5BC}'; // 🖼
|
||||
text = `${actorName} updated the group icon`;
|
||||
text = t('system.iconChanged', { actor: actorName });
|
||||
break;
|
||||
default:
|
||||
text = message.content ?? '';
|
||||
|
||||