Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60edb063dd | ||
| 0c85c36838 | |||
|
|
d59646c630 | ||
| c1985eda15 | |||
|
|
2e018b0367 | ||
| 8046474b8a | |||
|
|
9821b16968 | ||
| a887cd39f1 | |||
|
|
ac863b3c1a | ||
| a889b67c2e | |||
|
|
4618d59605 | ||
| 697f03bc51 | |||
|
|
670aaf6a20 | ||
| c4a6b4b175 | |||
| a5efb1f337 |
@@ -102,29 +102,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build Android APK
|
- name: Build Android APK
|
||||||
run: npm run android:apk:linux
|
run: npm run android:apk:linux
|
||||||
|
|
||||||
- name: Upload APK
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: Paarrot-Android.apk
|
|
||||||
path: android/app/build/outputs/apk/debug/Paarrot-*.apk
|
|
||||||
|
|
||||||
create-release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [increment-version, build-android]
|
|
||||||
if: always() && needs.build-android.result == 'success'
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
ref: ${{ github.ref_name }}
|
|
||||||
|
|
||||||
- name: Pull latest (after version bump)
|
|
||||||
if: github.ref == 'refs/heads/master'
|
|
||||||
run: git pull origin master
|
|
||||||
|
|
||||||
- name: Get version
|
- name: Get version
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
@@ -135,7 +113,14 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
|
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
|
||||||
echo "Version: $VERSION"
|
echo "Version: $VERSION"
|
||||||
|
|
||||||
|
- name: Prepare release files
|
||||||
|
run: |
|
||||||
|
mkdir -p release-files
|
||||||
|
find android/app/build/outputs/apk/debug -type f -name "Paarrot-*.apk" -exec cp {} release-files/ \;
|
||||||
|
ls -la release-files/
|
||||||
|
test -n "$(ls -A release-files/*.apk 2>/dev/null)"
|
||||||
|
|
||||||
- name: Check if tag exists
|
- name: Check if tag exists
|
||||||
id: tag_check
|
id: tag_check
|
||||||
run: |
|
run: |
|
||||||
@@ -147,7 +132,7 @@ jobs:
|
|||||||
echo "Tag v$VERSION does not exist"
|
echo "Tag v$VERSION does not exist"
|
||||||
echo "exists=false" >> $GITHUB_OUTPUT
|
echo "exists=false" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Create and push tag
|
- name: Create and push tag
|
||||||
if: steps.tag_check.outputs.exists == 'false'
|
if: steps.tag_check.outputs.exists == 'false'
|
||||||
run: |
|
run: |
|
||||||
@@ -155,18 +140,9 @@ jobs:
|
|||||||
git config user.email "actions@github.com"
|
git config user.email "actions@github.com"
|
||||||
git tag -a "v${{ steps.version.outputs.VERSION }}" -m "Release v${{ steps.version.outputs.VERSION }}"
|
git tag -a "v${{ steps.version.outputs.VERSION }}" -m "Release v${{ steps.version.outputs.VERSION }}"
|
||||||
git push origin "v${{ steps.version.outputs.VERSION }}"
|
git push origin "v${{ steps.version.outputs.VERSION }}"
|
||||||
|
|
||||||
- name: Download all artifacts
|
# Upload via the internal Gitea API. actions/upload-artifact times out against
|
||||||
uses: actions/download-artifact@v3
|
# git.ruv.wtf:443 from this runner; synbox:8418 is reachable on the LAN.
|
||||||
with:
|
|
||||||
path: artifacts
|
|
||||||
|
|
||||||
- name: Prepare release files
|
|
||||||
run: |
|
|
||||||
mkdir -p release-files
|
|
||||||
find artifacts -type f -name "*.apk" -exec cp {} release-files/ \;
|
|
||||||
ls -la release-files/
|
|
||||||
|
|
||||||
- name: Create or Update Gitea Release
|
- name: Create or Update Gitea Release
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -175,10 +151,10 @@ jobs:
|
|||||||
TAG_NAME="v${VERSION}"
|
TAG_NAME="v${VERSION}"
|
||||||
API_BASE="http://synbox.ruv.wtf:8418/api/v1"
|
API_BASE="http://synbox.ruv.wtf:8418/api/v1"
|
||||||
REPO="litruv/cinny-mobile"
|
REPO="litruv/cinny-mobile"
|
||||||
|
|
||||||
RELEASE_ID=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
|
RELEASE_ID=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
"${API_BASE}/repos/${REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty')
|
"${API_BASE}/repos/${REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty')
|
||||||
|
|
||||||
if [ -z "$RELEASE_ID" ]; then
|
if [ -z "$RELEASE_ID" ]; then
|
||||||
echo "Creating new release for ${TAG_NAME}..."
|
echo "Creating new release for ${TAG_NAME}..."
|
||||||
RELEASE_ID=$(curl -s -X POST \
|
RELEASE_ID=$(curl -s -X POST \
|
||||||
@@ -198,7 +174,7 @@ jobs:
|
|||||||
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
|
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Uploading assets..."
|
echo "Uploading assets..."
|
||||||
for FILE in release-files/*; do
|
for FILE in release-files/*; do
|
||||||
FILENAME="Paarrot-${VERSION}.apk"
|
FILENAME="Paarrot-${VERSION}.apk"
|
||||||
@@ -210,9 +186,9 @@ jobs:
|
|||||||
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}"
|
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}"
|
||||||
echo ""
|
echo ""
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "Gitea release complete!"
|
echo "Gitea release complete!"
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GHTOKEN }}
|
GH_TOKEN: ${{ secrets.GHTOKEN }}
|
||||||
@@ -220,10 +196,10 @@ jobs:
|
|||||||
VERSION="${{ steps.version.outputs.VERSION }}"
|
VERSION="${{ steps.version.outputs.VERSION }}"
|
||||||
TAG_NAME="v${VERSION}"
|
TAG_NAME="v${VERSION}"
|
||||||
GITHUB_REPO="Paarrot/Paarrot-Mobile"
|
GITHUB_REPO="Paarrot/Paarrot-Mobile"
|
||||||
|
|
||||||
RELEASE_ID=$(curl -s -H "Authorization: token ${GH_TOKEN}" \
|
RELEASE_ID=$(curl -s -H "Authorization: token ${GH_TOKEN}" \
|
||||||
"https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty')
|
"https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty')
|
||||||
|
|
||||||
if [ -z "$RELEASE_ID" ]; then
|
if [ -z "$RELEASE_ID" ]; then
|
||||||
echo "Creating new GitHub release for ${TAG_NAME}..."
|
echo "Creating new GitHub release for ${TAG_NAME}..."
|
||||||
RELEASE_ID=$(curl -s -X POST \
|
RELEASE_ID=$(curl -s -X POST \
|
||||||
@@ -235,11 +211,11 @@ jobs:
|
|||||||
else
|
else
|
||||||
echo "GitHub release exists with ID: ${RELEASE_ID}"
|
echo "GitHub release exists with ID: ${RELEASE_ID}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Uploading assets to GitHub..."
|
echo "Uploading assets to GitHub..."
|
||||||
UPLOAD_URL=$(curl -s -H "Authorization: token ${GH_TOKEN}" \
|
UPLOAD_URL=$(curl -s -H "Authorization: token ${GH_TOKEN}" \
|
||||||
"https://api.github.com/repos/${GITHUB_REPO}/releases/${RELEASE_ID}" | jq -r '.upload_url' | sed 's/{?name,label}//')
|
"https://api.github.com/repos/${GITHUB_REPO}/releases/${RELEASE_ID}" | jq -r '.upload_url' | sed 's/{?name,label}//')
|
||||||
|
|
||||||
for FILE in release-files/*; do
|
for FILE in release-files/*; do
|
||||||
FILENAME="Paarrot-${VERSION}.apk"
|
FILENAME="Paarrot-${VERSION}.apk"
|
||||||
echo "Uploading ${FILENAME} to GitHub..."
|
echo "Uploading ${FILENAME} to GitHub..."
|
||||||
@@ -250,5 +226,5 @@ jobs:
|
|||||||
"${UPLOAD_URL}?name=${FILENAME}"
|
"${UPLOAD_URL}?name=${FILENAME}"
|
||||||
echo ""
|
echo ""
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "GitHub release complete!"
|
echo "GitHub release complete!"
|
||||||
|
|||||||
2
.gitmodules
vendored
2
.gitmodules
vendored
@@ -1,3 +1,3 @@
|
|||||||
[submodule "cinny"]
|
[submodule "cinny"]
|
||||||
path = cinny
|
path = cinny
|
||||||
url = http://synbox.ruv.wtf:8418/litruv/cinny.git
|
url = ../cinny.git
|
||||||
|
|||||||
@@ -151,6 +151,21 @@ class MatrixSyncService : Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum class RoomNotifyMode {
|
||||||
|
MUTE,
|
||||||
|
ALL_MESSAGES,
|
||||||
|
MENTIONS_AND_KEYWORDS,
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class NotifyContext(
|
||||||
|
val myUserId: String,
|
||||||
|
val myDisplayName: String?,
|
||||||
|
val myLocalpart: String?,
|
||||||
|
val keywords: List<String>,
|
||||||
|
val directRoomIds: Set<String>,
|
||||||
|
val pushRules: JSONObject?,
|
||||||
|
)
|
||||||
|
|
||||||
private fun processRoomEvents(sync: JSONObject, myUserId: String) {
|
private fun processRoomEvents(sync: JSONObject, myUserId: String) {
|
||||||
val prefs = applicationContext.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
|
val prefs = applicationContext.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
|
||||||
val homeserver = prefs.getString(EXTRA_HOMESERVER, null)?.trimEnd('/') ?: return
|
val homeserver = prefs.getString(EXTRA_HOMESERVER, null)?.trimEnd('/') ?: return
|
||||||
@@ -159,10 +174,20 @@ class MatrixSyncService : Service() {
|
|||||||
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
ensureMessageChannel(nm)
|
ensureMessageChannel(nm)
|
||||||
|
|
||||||
|
val notifyCtx = loadNotifyContext(homeserver, token, myUserId)
|
||||||
|
|
||||||
val roomIds = joinedRooms.keys().asSequence().toList()
|
val roomIds = joinedRooms.keys().asSequence().toList()
|
||||||
for (roomId in roomIds) {
|
for (roomId in roomIds) {
|
||||||
val timeline = joinedRooms.optJSONObject(roomId)
|
val roomData = joinedRooms.optJSONObject(roomId) ?: continue
|
||||||
?.optJSONObject("timeline") ?: continue
|
val mode = resolveRoomNotifyMode(roomId, notifyCtx)
|
||||||
|
|
||||||
|
// Homeserver unread counts already apply push rules — skip rooms with nothing to notify.
|
||||||
|
val unread = roomData.optJSONObject("unread_notifications")
|
||||||
|
val notificationCount = unread?.optInt("notification_count", 0) ?: 0
|
||||||
|
if (mode == RoomNotifyMode.MUTE || notificationCount <= 0) continue
|
||||||
|
|
||||||
|
val mentionsOnly = mode == RoomNotifyMode.MENTIONS_AND_KEYWORDS
|
||||||
|
val timeline = roomData.optJSONObject("timeline") ?: continue
|
||||||
val events = timeline.optJSONArray("events") ?: continue
|
val events = timeline.optJSONArray("events") ?: continue
|
||||||
|
|
||||||
for (i in 0 until events.length()) {
|
for (i in 0 until events.length()) {
|
||||||
@@ -174,6 +199,8 @@ class MatrixSyncService : Service() {
|
|||||||
if (eventId.isNotBlank() && !shownEventIds.add(eventId)) continue
|
if (eventId.isNotBlank() && !shownEventIds.add(eventId)) continue
|
||||||
|
|
||||||
val content = event.optJSONObject("content") ?: continue
|
val content = event.optJSONObject("content") ?: continue
|
||||||
|
if (mentionsOnly && !isSpecialMessage(content, notifyCtx)) continue
|
||||||
|
|
||||||
val msgtype = content.optString("msgtype")
|
val msgtype = content.optString("msgtype")
|
||||||
val rawBody = content.optString("body")
|
val rawBody = content.optString("body")
|
||||||
val body = when (msgtype) {
|
val body = when (msgtype) {
|
||||||
@@ -203,6 +230,144 @@ class MatrixSyncService : Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Load push rules, DM list, keywords, and own display name for notification filtering. */
|
||||||
|
private fun loadNotifyContext(homeserver: String, token: String, myUserId: String): NotifyContext {
|
||||||
|
val pushRules = fetchJson("$homeserver/_matrix/client/v3/pushrules/", token)
|
||||||
|
val accountDataDirect = fetchJson("$homeserver/_matrix/client/v3/user/${urlEncode(myUserId)}/account_data/m.direct", token)
|
||||||
|
val profile = fetchJson("$homeserver/_matrix/client/v3/profile/${urlEncode(myUserId)}", token)
|
||||||
|
|
||||||
|
val directRoomIds = mutableSetOf<String>()
|
||||||
|
accountDataDirect?.keys()?.forEach { key ->
|
||||||
|
val rooms = accountDataDirect.optJSONArray(key) ?: return@forEach
|
||||||
|
for (i in 0 until rooms.length()) {
|
||||||
|
rooms.optString(i).takeIf { it.isNotBlank() }?.let { directRoomIds.add(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val keywords = mutableListOf<String>()
|
||||||
|
val contentRules = pushRules?.optJSONObject("global")?.optJSONArray("content")
|
||||||
|
if (contentRules != null) {
|
||||||
|
for (i in 0 until contentRules.length()) {
|
||||||
|
val rule = contentRules.optJSONObject(i) ?: continue
|
||||||
|
if (rule.optBoolean("enabled", true).not()) continue
|
||||||
|
if (!actionsIncludeNotify(rule.optJSONArray("actions"))) continue
|
||||||
|
rule.optString("pattern").takeIf { it.isNotBlank() }?.let { keywords.add(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NotifyContext(
|
||||||
|
myUserId = myUserId,
|
||||||
|
myDisplayName = profile?.optString("displayname")?.takeIf { it.isNotBlank() },
|
||||||
|
myLocalpart = myUserId.substringAfter("@").substringBefore(":").takeIf { it.isNotBlank() },
|
||||||
|
keywords = keywords,
|
||||||
|
directRoomIds = directRoomIds,
|
||||||
|
pushRules = pushRules,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors Cinny/Paarrot room notification modes:
|
||||||
|
* Mute override → mute; room notify → all; room dont_notify → mentions;
|
||||||
|
* unset DM → all; unset room → mentions (app Default).
|
||||||
|
*/
|
||||||
|
private fun resolveRoomNotifyMode(roomId: String, ctx: NotifyContext): RoomNotifyMode {
|
||||||
|
val global = ctx.pushRules?.optJSONObject("global")
|
||||||
|
// If push rules are unavailable, rely on homeserver unread_notifications only.
|
||||||
|
?: return RoomNotifyMode.ALL_MESSAGES
|
||||||
|
|
||||||
|
val overrides = global.optJSONArray("override")
|
||||||
|
if (overrides != null) {
|
||||||
|
for (i in 0 until overrides.length()) {
|
||||||
|
val rule = overrides.optJSONObject(i) ?: continue
|
||||||
|
if (rule.optString("rule_id") != roomId) continue
|
||||||
|
if (rule.optBoolean("enabled", true).not()) continue
|
||||||
|
if (!actionsIncludeNotify(rule.optJSONArray("actions"))) {
|
||||||
|
return RoomNotifyMode.MUTE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val roomRules = global.optJSONArray("room")
|
||||||
|
if (roomRules != null) {
|
||||||
|
for (i in 0 until roomRules.length()) {
|
||||||
|
val rule = roomRules.optJSONObject(i) ?: continue
|
||||||
|
if (rule.optString("rule_id") != roomId) continue
|
||||||
|
if (rule.optBoolean("enabled", true).not()) continue
|
||||||
|
return if (actionsIncludeNotify(rule.optJSONArray("actions"))) {
|
||||||
|
RoomNotifyMode.ALL_MESSAGES
|
||||||
|
} else {
|
||||||
|
RoomNotifyMode.MENTIONS_AND_KEYWORDS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (ctx.directRoomIds.contains(roomId)) {
|
||||||
|
RoomNotifyMode.ALL_MESSAGES
|
||||||
|
} else {
|
||||||
|
RoomNotifyMode.MENTIONS_AND_KEYWORDS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun actionsIncludeNotify(actions: org.json.JSONArray?): Boolean {
|
||||||
|
if (actions == null) return false
|
||||||
|
for (i in 0 until actions.length()) {
|
||||||
|
when (val action = actions.opt(i)) {
|
||||||
|
is String -> if (action == "notify") return true
|
||||||
|
is JSONObject -> if (action.has("set_tweak")) { /* tweaks only */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isSpecialMessage(content: JSONObject, ctx: NotifyContext): Boolean {
|
||||||
|
val mentions = content.optJSONObject("m.mentions")
|
||||||
|
if (mentions != null) {
|
||||||
|
val userIds = mentions.optJSONArray("user_ids")
|
||||||
|
if (userIds != null) {
|
||||||
|
for (i in 0 until userIds.length()) {
|
||||||
|
if (userIds.optString(i) == ctx.myUserId) return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mentions.optBoolean("room", false)) return true
|
||||||
|
}
|
||||||
|
|
||||||
|
val body = content.optString("body").lowercase()
|
||||||
|
if (body.isBlank()) return false
|
||||||
|
if (body.contains("@room")) return true
|
||||||
|
ctx.myDisplayName?.lowercase()?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
if (body.contains(it)) return true
|
||||||
|
}
|
||||||
|
ctx.myLocalpart?.lowercase()?.let {
|
||||||
|
if (body.contains(it)) return true
|
||||||
|
}
|
||||||
|
for (keyword in ctx.keywords) {
|
||||||
|
if (body.contains(keyword.lowercase())) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun urlEncode(value: String): String = URLEncoder.encode(value, "UTF-8")
|
||||||
|
|
||||||
|
private fun fetchJson(urlString: String, token: String): JSONObject? {
|
||||||
|
return try {
|
||||||
|
val conn = URL(urlString).openConnection() as HttpURLConnection
|
||||||
|
try {
|
||||||
|
conn.requestMethod = "GET"
|
||||||
|
conn.setRequestProperty("Authorization", "Bearer $token")
|
||||||
|
conn.setRequestProperty("Accept", "application/json")
|
||||||
|
conn.connectTimeout = 4_000
|
||||||
|
conn.readTimeout = 6_000
|
||||||
|
if (conn.responseCode != 200) return null
|
||||||
|
JSONObject(conn.inputStream.bufferedReader().readText())
|
||||||
|
} finally {
|
||||||
|
conn.disconnect()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to fetch $urlString: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun resolveProfile(mxid: String, homeserver: String, token: String): UserProfile {
|
private fun resolveProfile(mxid: String, homeserver: String, token: String): UserProfile {
|
||||||
val cachedName = displayNameCache[mxid]
|
val cachedName = displayNameCache[mxid]
|
||||||
if (cachedName != null) return UserProfile(cachedName, avatarCache[mxid])
|
if (cachedName != null) return UserProfile(cachedName, avatarCache[mxid])
|
||||||
|
|||||||
2
cinny
2
cinny
Submodule cinny updated: 9c7f71896c...b52926f7d8
@@ -7,6 +7,7 @@ import * as swipeCss from '../mobile/mobile-gestures.css';
|
|||||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
import { SidebarDockedCallPanel } from '../../features/call/SidebarDockedCallPanel';
|
import { SidebarDockedCallPanel } from '../../features/call/SidebarDockedCallPanel';
|
||||||
import { useShowCompactMasterView } from '../../hooks/useCompactNav';
|
import { useShowCompactMasterView } from '../../hooks/useCompactNav';
|
||||||
|
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
|
||||||
|
|
||||||
type PageRootProps = {
|
type PageRootProps = {
|
||||||
nav: ReactNode;
|
nav: ReactNode;
|
||||||
@@ -74,6 +75,7 @@ export function PageNav({
|
|||||||
grow={isMobile ? 'Yes' : undefined}
|
grow={isMobile ? 'Yes' : undefined}
|
||||||
className={classNames(css.PageNav({ size }), className)}
|
className={classNames(css.PageNav({ size }), className)}
|
||||||
shrink={isMobile ? 'Yes' : 'No'}
|
shrink={isMobile ? 'Yes' : 'No'}
|
||||||
|
data-page-nav=""
|
||||||
>
|
>
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
{children}
|
{children}
|
||||||
@@ -89,6 +91,7 @@ export const PageNavHeader = as<'header', css.PageNavHeaderVariants>(
|
|||||||
className={classNames(css.PageNavHeader({ outlined }), className)}
|
className={classNames(css.PageNavHeader({ outlined }), className)}
|
||||||
variant="Background"
|
variant="Background"
|
||||||
size="600"
|
size="600"
|
||||||
|
data-folder-tab="nav"
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
@@ -104,13 +107,16 @@ export function PageNavContent({
|
|||||||
scrollRef?: MutableRefObject<HTMLDivElement | null>;
|
scrollRef?: MutableRefObject<HTMLDivElement | null>;
|
||||||
scrollProps?: React.ComponentProps<typeof Scroll>;
|
scrollProps?: React.ComponentProps<typeof Scroll>;
|
||||||
}) {
|
}) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const hideScrollbar = isStationeryTheme(theme);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
<Scroll
|
<Scroll
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
variant="Background"
|
variant="Background"
|
||||||
direction="Vertical"
|
direction="Vertical"
|
||||||
size="300"
|
size={hideScrollbar ? '0' : '300'}
|
||||||
hideTrack
|
hideTrack
|
||||||
visibility="Hover"
|
visibility="Hover"
|
||||||
{...scrollProps}
|
{...scrollProps}
|
||||||
@@ -126,6 +132,7 @@ export const Page = as<'div'>(({ className, ...props }, ref) => (
|
|||||||
grow="Yes"
|
grow="Yes"
|
||||||
direction="Column"
|
direction="Column"
|
||||||
className={classNames(ContainerColor({ variant: 'Surface' }), className)}
|
className={classNames(ContainerColor({ variant: 'Surface' }), className)}
|
||||||
|
data-page=""
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
@@ -137,6 +144,7 @@ export const PageHeader = as<'div', css.PageHeaderVariants>(
|
|||||||
as="header"
|
as="header"
|
||||||
size="600"
|
size="600"
|
||||||
className={classNames(css.PageHeader({ balance, outlined }), className)}
|
className={classNames(css.PageHeader({ balance, outlined }), className)}
|
||||||
|
data-folder-tab-bar=""
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
|
|||||||
136
overlay/src/app/features/forum/ForumBoardDetail.tsx
Normal file
136
overlay/src/app/features/forum/ForumBoardDetail.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Box, Scroll, Text, color } from 'folds';
|
||||||
|
import { Icon, Icons } from '../../components/icons';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { useCompactNav } from '../../hooks/useCompactNav';
|
||||||
|
import { Page, PageContent } from '../../components/page';
|
||||||
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
|
import { activeThreadIdAtomFamily } from '../../state/activeThread';
|
||||||
|
import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
|
||||||
|
import { getSpacePath } from '../../pages/pathUtils';
|
||||||
|
import { useForumBoardContext } from './ForumBoardContext';
|
||||||
|
import { ForumThreadDetail } from './ForumThreadDetail';
|
||||||
|
import type { ForumPost } from './types';
|
||||||
|
import * as theme from './forumTheme.css';
|
||||||
|
|
||||||
|
/** Thread detail in the main pane; post list lives in ForumFeedSidebar. */
|
||||||
|
export function ForumBoardDetail() {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const compact = useCompactNav();
|
||||||
|
const [optimisticPost, setOptimisticPost] = useState<ForumPost | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
forumSpace,
|
||||||
|
query,
|
||||||
|
error,
|
||||||
|
selectedPostId,
|
||||||
|
activeTopicRoomId,
|
||||||
|
threads,
|
||||||
|
recordThreadReply,
|
||||||
|
} = useForumBoardContext();
|
||||||
|
|
||||||
|
// Compact: /feed/ without a selected post has no detail content — return to the list
|
||||||
|
// so an empty detail layer cannot cover the forum sidebar.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!compact || selectedPostId) return;
|
||||||
|
navigate(getSpacePath(getCanonicalAliasOrRoomId(mx, forumSpace.roomId)), { replace: true });
|
||||||
|
}, [compact, selectedPostId, navigate, mx, forumSpace.roomId]);
|
||||||
|
|
||||||
|
const activeTopicRoom = activeTopicRoomId ? mx.getRoom(activeTopicRoomId) : null;
|
||||||
|
const topicSelected = Boolean(query.topic);
|
||||||
|
|
||||||
|
const setActiveThreadId = useSetAtom(
|
||||||
|
activeThreadIdAtomFamily(activeTopicRoomId ?? forumSpace.roomId)
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedPostId && activeTopicRoomId) {
|
||||||
|
setActiveThreadId(selectedPostId);
|
||||||
|
return () => setActiveThreadId(undefined);
|
||||||
|
}
|
||||||
|
setActiveThreadId(undefined);
|
||||||
|
return undefined;
|
||||||
|
}, [selectedPostId, activeTopicRoomId, setActiveThreadId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (optimisticPost && optimisticPost.eventId !== selectedPostId) {
|
||||||
|
setOptimisticPost(null);
|
||||||
|
}
|
||||||
|
}, [optimisticPost, selectedPostId]);
|
||||||
|
|
||||||
|
const selectedThread = useMemo(
|
||||||
|
() => threads.find((t) => t.eventId === selectedPostId),
|
||||||
|
[threads, selectedPostId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleReplySent = useCallback(
|
||||||
|
(_reply: ForumPost) => {
|
||||||
|
if (selectedPostId) {
|
||||||
|
recordThreadReply(selectedPostId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[recordThreadReply, selectedPostId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const showThread = Boolean(selectedPostId && activeTopicRoom);
|
||||||
|
const threadInitialPost =
|
||||||
|
optimisticPost?.eventId === selectedPostId ? optimisticPost : null;
|
||||||
|
const detailDropRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page className={classNames(ContainerColor({ variant: 'Background' }), theme.ForumAppRoot)}>
|
||||||
|
<PageContent className={theme.ForumPageContent}>
|
||||||
|
{error && (
|
||||||
|
<Text as="p" style={{ color: color.Critical.Main, padding: '0.5rem 1rem' }}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<aside className={theme.ForumDetailPane}>
|
||||||
|
{showThread && activeTopicRoom ? (
|
||||||
|
<div ref={detailDropRef} className={theme.ForumTopicPostDetailThreadLayout}>
|
||||||
|
<ForumThreadDetail
|
||||||
|
roomId={activeTopicRoom.roomId}
|
||||||
|
rootEventId={selectedPostId!}
|
||||||
|
titleHint={selectedThread?.title}
|
||||||
|
initialPost={threadInitialPost}
|
||||||
|
fileDropContainerRef={detailDropRef}
|
||||||
|
onReplySent={handleReplySent}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Scroll
|
||||||
|
id="topicPostDetail"
|
||||||
|
className={theme.ForumTopicPostDetailScroll}
|
||||||
|
variant="Surface"
|
||||||
|
direction="Vertical"
|
||||||
|
size="300"
|
||||||
|
hideTrack
|
||||||
|
visibility="Hover"
|
||||||
|
>
|
||||||
|
<div className={theme.ForumTopicPostDetailInner}>
|
||||||
|
<Box
|
||||||
|
className={theme.ForumEmptyDetail}
|
||||||
|
direction="Column"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
gap="300"
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Thread} size="600" />
|
||||||
|
<Text size="T300" priority="300">
|
||||||
|
{topicSelected
|
||||||
|
? 'Select a post to read the thread'
|
||||||
|
: 'Pick a category and topic, then choose a post'}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
</Scroll>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</PageContent>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
431
overlay/src/app/features/forum/useForumBoard.ts
Normal file
431
overlay/src/app/features/forum/useForumBoard.ts
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { MatrixEvent, Room, RoomStateEvent } from 'matrix-js-sdk';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
||||||
|
import { useCompactNav } from '../../hooks/useCompactNav';
|
||||||
|
import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
|
||||||
|
import { getSpaceFeedPath, getSpaceLobbyPath, getSpacePath } from '../../pages/pathUtils';
|
||||||
|
import { createRoomModalAtom } from '../../state/createRoomModal';
|
||||||
|
import { createSpaceModalAtom } from '../../state/createSpaceModal';
|
||||||
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
|
import {
|
||||||
|
buildForumSections,
|
||||||
|
buildTopicThreads,
|
||||||
|
buildThreadSummaries,
|
||||||
|
} from './forumTopicHelpers';
|
||||||
|
import { applyTopicStatusSort } from './topicSearch';
|
||||||
|
import {
|
||||||
|
createForumPostFromPayload,
|
||||||
|
listForumTopics,
|
||||||
|
listTopicFeedPosts,
|
||||||
|
loadAggregatedTopicFeed,
|
||||||
|
} from './forumFeed';
|
||||||
|
import type {
|
||||||
|
ForumBoardQuery,
|
||||||
|
ForumPost,
|
||||||
|
ForumPublishedPost,
|
||||||
|
ForumSection,
|
||||||
|
ForumThreadSummary,
|
||||||
|
} from './types';
|
||||||
|
import { useForumRoomLiveUpdates } from './useForumRoomLiveUpdates';
|
||||||
|
|
||||||
|
export type ForumBoardScope = {
|
||||||
|
forumSpaceId: string;
|
||||||
|
/** When set, only show posts from this topic room. */
|
||||||
|
topicRoomId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function scopesFromSelection(
|
||||||
|
sections: ForumSection[],
|
||||||
|
category?: string,
|
||||||
|
topic?: string
|
||||||
|
): Array<{ roomId: string; sectionTitle?: string; topicName?: string }> {
|
||||||
|
if (topic) {
|
||||||
|
for (const section of sections) {
|
||||||
|
const match = section.topics.find((t) => t.roomId === topic);
|
||||||
|
if (match) {
|
||||||
|
return [{ roomId: match.roomId, sectionTitle: section.title, topicName: match.name }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [{ roomId: topic }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
const section = sections.find((s) => s.title === category);
|
||||||
|
if (!section) return [];
|
||||||
|
return section.topics.map((t) => ({
|
||||||
|
roomId: t.roomId,
|
||||||
|
sectionTitle: section.title,
|
||||||
|
topicName: t.name,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections.flatMap((section) =>
|
||||||
|
section.topics.map((t) => ({
|
||||||
|
roomId: t.roomId,
|
||||||
|
sectionTitle: section.title,
|
||||||
|
topicName: t.name,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useForumBoard(scope: ForumBoardScope, forumSpace: Room) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const selectedRoomId = useSelectedRoom();
|
||||||
|
const compact = useCompactNav();
|
||||||
|
|
||||||
|
const searchParams = useMemo(
|
||||||
|
() => new URLSearchParams(location.search),
|
||||||
|
[location.search]
|
||||||
|
);
|
||||||
|
|
||||||
|
const filterQuery: ForumBoardQuery = useMemo(
|
||||||
|
() => ({
|
||||||
|
category: searchParams.get('category') || undefined,
|
||||||
|
topic: searchParams.get('topic') || undefined,
|
||||||
|
q: searchParams.get('q') || undefined,
|
||||||
|
status: (searchParams.get('status') as ForumBoardQuery['status']) || 'all',
|
||||||
|
sort: (searchParams.get('sort') as ForumBoardQuery['sort']) || 'hot',
|
||||||
|
}),
|
||||||
|
[searchParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
const query: ForumBoardQuery = useMemo(
|
||||||
|
() => ({
|
||||||
|
...filterQuery,
|
||||||
|
post: searchParams.get('post') || undefined,
|
||||||
|
postRoom: searchParams.get('postRoom') || undefined,
|
||||||
|
}),
|
||||||
|
[filterQuery, searchParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [sections, setSections] = useState<ForumSection[]>([]);
|
||||||
|
const [threads, setThreads] = useState<ForumThreadSummary[]>([]);
|
||||||
|
const [loadingSections, setLoadingSections] = useState(true);
|
||||||
|
const [loadingThreads, setLoadingThreads] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [postsNextBatch, setPostsNextBatch] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const applyForumSearchParams = useCallback(
|
||||||
|
(mutate: (next: URLSearchParams) => void) => {
|
||||||
|
const next = new URLSearchParams(location.search);
|
||||||
|
mutate(next);
|
||||||
|
const search = next.toString();
|
||||||
|
const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, forumSpace.roomId);
|
||||||
|
|
||||||
|
const onTopicRoomRoute =
|
||||||
|
Boolean(selectedRoomId) && selectedRoomId !== forumSpace.roomId;
|
||||||
|
|
||||||
|
// Compact master view only mounts the post list on /:spaceId/. Opening a post
|
||||||
|
// must switch to /feed/ so ForumFeedPage mounts as the detail pane.
|
||||||
|
let pathname = location.pathname;
|
||||||
|
if (onTopicRoomRoute) {
|
||||||
|
pathname = getSpaceLobbyPath(spaceIdOrAlias);
|
||||||
|
} else if (next.get('post')) {
|
||||||
|
pathname = getSpaceFeedPath(spaceIdOrAlias);
|
||||||
|
} else if (compact) {
|
||||||
|
pathname = getSpacePath(spaceIdOrAlias);
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate(
|
||||||
|
{
|
||||||
|
pathname,
|
||||||
|
search: search ? `?${search}` : '',
|
||||||
|
},
|
||||||
|
{ replace: true }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
location.pathname,
|
||||||
|
location.search,
|
||||||
|
navigate,
|
||||||
|
mx,
|
||||||
|
forumSpace.roomId,
|
||||||
|
selectedRoomId,
|
||||||
|
compact,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setQueryParam = useCallback(
|
||||||
|
(key: string, value: string | null) => {
|
||||||
|
applyForumSearchParams((next) => {
|
||||||
|
if (!value) next.delete(key);
|
||||||
|
else next.set(key, value);
|
||||||
|
if (key !== 'post') {
|
||||||
|
next.delete('post');
|
||||||
|
next.delete('postRoom');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[applyForumSearchParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setCategory = useCallback(
|
||||||
|
(category: string | null) => {
|
||||||
|
applyForumSearchParams((next) => {
|
||||||
|
if (!category) next.delete('category');
|
||||||
|
else next.set('category', category);
|
||||||
|
next.delete('topic');
|
||||||
|
next.delete('post');
|
||||||
|
next.delete('postRoom');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[applyForumSearchParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadSections = useCallback(async () => {
|
||||||
|
setLoadingSections(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const topics = await listForumTopics(mx, scope.forumSpaceId);
|
||||||
|
setSections(buildForumSections(topics));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load forum categories');
|
||||||
|
} finally {
|
||||||
|
setLoadingSections(false);
|
||||||
|
}
|
||||||
|
}, [mx, scope.forumSpaceId]);
|
||||||
|
|
||||||
|
const refreshThreads = useCallback(async (options?: { silent?: boolean }) => {
|
||||||
|
if (!options?.silent) setLoadingThreads(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const roomScopes = scopesFromSelection(sections, filterQuery.category, filterQuery.topic);
|
||||||
|
if (roomScopes.length === 0) {
|
||||||
|
setThreads([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let posts;
|
||||||
|
if (roomScopes.length === 1) {
|
||||||
|
const result = await listTopicFeedPosts(mx, roomScopes[0].roomId);
|
||||||
|
posts = result.posts;
|
||||||
|
setPostsNextBatch(result.nextBatch);
|
||||||
|
const scopeMeta = roomScopes[0];
|
||||||
|
posts.forEach((post) => {
|
||||||
|
post.topicRoomId = scopeMeta.roomId;
|
||||||
|
if (scopeMeta.sectionTitle) post.sectionTitle = scopeMeta.sectionTitle;
|
||||||
|
if (scopeMeta.topicName) post.topicName = scopeMeta.topicName;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
posts = await loadAggregatedTopicFeed(mx, roomScopes);
|
||||||
|
setPostsNextBatch(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
setThreads(buildTopicThreads(posts, filterQuery));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load forum posts');
|
||||||
|
} finally {
|
||||||
|
if (!options?.silent) setLoadingThreads(false);
|
||||||
|
}
|
||||||
|
}, [mx, sections, filterQuery]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSections();
|
||||||
|
}, [loadSections]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSpaceChild = (event: MatrixEvent) => {
|
||||||
|
if (event.getType() !== StateEvent.SpaceChild) return;
|
||||||
|
const roomId = event.getRoomId();
|
||||||
|
if (!roomId) return;
|
||||||
|
if (
|
||||||
|
roomId === forumSpace.roomId ||
|
||||||
|
sections.some((section) => section.spaceId === roomId)
|
||||||
|
) {
|
||||||
|
loadSections();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
mx.on(RoomStateEvent.Events, handleSpaceChild);
|
||||||
|
return () => {
|
||||||
|
mx.removeListener(RoomStateEvent.Events, handleSpaceChild);
|
||||||
|
};
|
||||||
|
}, [mx, forumSpace.roomId, sections, loadSections]);
|
||||||
|
|
||||||
|
const createSpaceModal = useAtomValue(createSpaceModalAtom);
|
||||||
|
const createRoomModal = useAtomValue(createRoomModalAtom);
|
||||||
|
const prevCreateSpaceModal = useRef(createSpaceModal);
|
||||||
|
const prevCreateRoomModal = useRef(createRoomModal);
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevCreateSpaceModal.current && !createSpaceModal) {
|
||||||
|
loadSections();
|
||||||
|
}
|
||||||
|
if (prevCreateRoomModal.current && !createRoomModal) {
|
||||||
|
loadSections();
|
||||||
|
}
|
||||||
|
prevCreateSpaceModal.current = createSpaceModal;
|
||||||
|
prevCreateRoomModal.current = createRoomModal;
|
||||||
|
}, [createSpaceModal, createRoomModal, loadSections]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loadingSections) return;
|
||||||
|
void refreshThreads();
|
||||||
|
}, [
|
||||||
|
loadingSections,
|
||||||
|
refreshThreads,
|
||||||
|
filterQuery.category,
|
||||||
|
filterQuery.topic,
|
||||||
|
filterQuery.q,
|
||||||
|
filterQuery.status,
|
||||||
|
filterQuery.sort,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const scopedRooms = useMemo(() => {
|
||||||
|
if (loadingSections) return [];
|
||||||
|
const roomScopes = scopesFromSelection(sections, filterQuery.category, filterQuery.topic);
|
||||||
|
return roomScopes
|
||||||
|
.map((scope) => mx.getRoom(scope.roomId))
|
||||||
|
.filter((room): room is Room => room !== null);
|
||||||
|
}, [loadingSections, sections, filterQuery.category, filterQuery.topic, mx]);
|
||||||
|
|
||||||
|
useForumRoomLiveUpdates(scopedRooms, () => {
|
||||||
|
void refreshThreads({ silent: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedPostId = query.post ?? null;
|
||||||
|
const selectedThread = threads.find((t) => t.eventId === selectedPostId);
|
||||||
|
const activeTopicRoomId =
|
||||||
|
query.postRoom || selectedThread?.topicRoomId || query.topic || scope.topicRoomId || null;
|
||||||
|
|
||||||
|
const selectPost = useCallback(
|
||||||
|
(eventId: string | null, topicRoomId?: string) => {
|
||||||
|
applyForumSearchParams((next) => {
|
||||||
|
if (!eventId) {
|
||||||
|
next.delete('post');
|
||||||
|
next.delete('postRoom');
|
||||||
|
} else {
|
||||||
|
next.set('post', eventId);
|
||||||
|
if (topicRoomId) next.set('postRoom', topicRoomId);
|
||||||
|
else next.delete('postRoom');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[applyForumSearchParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
const insertThreadFromNewPost = useCallback(
|
||||||
|
(published: ForumPublishedPost): ForumPost | null => {
|
||||||
|
const roomScopes = scopesFromSelection(sections, filterQuery.category, filterQuery.topic);
|
||||||
|
const inScope =
|
||||||
|
filterQuery.topic === published.topicRoomId ||
|
||||||
|
(!filterQuery.topic &&
|
||||||
|
(filterQuery.category
|
||||||
|
? roomScopes.some((s) => s.roomId === published.topicRoomId)
|
||||||
|
: true));
|
||||||
|
|
||||||
|
const scopeMeta = roomScopes.find((s) => s.roomId === published.topicRoomId);
|
||||||
|
const post = createForumPostFromPayload(
|
||||||
|
mx,
|
||||||
|
published.topicRoomId,
|
||||||
|
published.eventId,
|
||||||
|
{
|
||||||
|
title: published.title,
|
||||||
|
plainText: published.plainText,
|
||||||
|
formattedHtml: published.formattedHtml,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
topicRoomId: published.topicRoomId,
|
||||||
|
sectionTitle: scopeMeta?.sectionTitle,
|
||||||
|
topicName: scopeMeta?.topicName,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (inScope) {
|
||||||
|
const [summary] = buildThreadSummaries([post]);
|
||||||
|
if (summary) {
|
||||||
|
setThreads((prev) => {
|
||||||
|
const without = prev.filter((t) => t.eventId !== summary.eventId);
|
||||||
|
const merged = [summary, ...without];
|
||||||
|
return applyTopicStatusSort(merged, filterQuery.status || 'all', filterQuery.sort || 'hot');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return post;
|
||||||
|
},
|
||||||
|
[mx, sections, filterQuery]
|
||||||
|
);
|
||||||
|
|
||||||
|
const recordThreadReply = useCallback((rootEventId: string) => {
|
||||||
|
setThreads((prev) =>
|
||||||
|
prev.map((t) =>
|
||||||
|
t.eventId === rootEventId
|
||||||
|
? {
|
||||||
|
...t,
|
||||||
|
totalReplies: t.totalReplies + 1,
|
||||||
|
lastActivityTs: Date.now(),
|
||||||
|
}
|
||||||
|
: t
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadMorePosts = useCallback(async () => {
|
||||||
|
if (!postsNextBatch || loadingMore) return;
|
||||||
|
const roomScopes = scopesFromSelection(sections, filterQuery.category, filterQuery.topic);
|
||||||
|
if (roomScopes.length !== 1) return;
|
||||||
|
|
||||||
|
setLoadingMore(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const scopeMeta = roomScopes[0];
|
||||||
|
const result = await listTopicFeedPosts(mx, scopeMeta.roomId, {
|
||||||
|
from: postsNextBatch,
|
||||||
|
minRoots: 1,
|
||||||
|
});
|
||||||
|
setPostsNextBatch(result.nextBatch);
|
||||||
|
result.posts.forEach((post) => {
|
||||||
|
post.topicRoomId = scopeMeta.roomId;
|
||||||
|
if (scopeMeta.sectionTitle) post.sectionTitle = scopeMeta.sectionTitle;
|
||||||
|
if (scopeMeta.topicName) post.topicName = scopeMeta.topicName;
|
||||||
|
});
|
||||||
|
setThreads((prev) => {
|
||||||
|
const existingIds = new Set(prev.map((t) => t.eventId));
|
||||||
|
const added = buildTopicThreads(result.posts, filterQuery).filter(
|
||||||
|
(t) => !existingIds.has(t.eventId)
|
||||||
|
);
|
||||||
|
const merged = [...prev, ...added];
|
||||||
|
const sort = filterQuery.sort || 'hot';
|
||||||
|
merged.sort((a, b) => {
|
||||||
|
if (sort === 'new') return b.timestamp - a.timestamp;
|
||||||
|
if (sort === 'top') return b.totalReplies - a.totalReplies;
|
||||||
|
const hotA = a.totalReplies * 4 + Math.floor((a.lastActivityTs - a.timestamp) / 3600000);
|
||||||
|
const hotB = b.totalReplies * 4 + Math.floor((b.lastActivityTs - b.timestamp) / 3600000);
|
||||||
|
return hotB - hotA;
|
||||||
|
});
|
||||||
|
return merged;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load older posts');
|
||||||
|
} finally {
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
}, [postsNextBatch, loadingMore, sections, filterQuery, mx]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
forumSpace,
|
||||||
|
sections,
|
||||||
|
threads,
|
||||||
|
query,
|
||||||
|
loadingSections,
|
||||||
|
loadingThreads,
|
||||||
|
error,
|
||||||
|
selectedPostId,
|
||||||
|
selectedThread,
|
||||||
|
activeTopicRoomId,
|
||||||
|
setQueryParam,
|
||||||
|
setCategory,
|
||||||
|
selectPost,
|
||||||
|
refreshThreads,
|
||||||
|
insertThreadFromNewPost,
|
||||||
|
recordThreadReply,
|
||||||
|
loadMorePosts,
|
||||||
|
hasMorePosts: Boolean(postsNextBatch),
|
||||||
|
loadingMore,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -802,8 +802,9 @@ export const Message = as<'div', MessageProps>(
|
|||||||
gap="300"
|
gap="300"
|
||||||
direction={messageLayout === MessageLayout.Compact ? 'RowReverse' : 'Row'}
|
direction={messageLayout === MessageLayout.Compact ? 'RowReverse' : 'Row'}
|
||||||
justifyContent="SpaceBetween"
|
justifyContent="SpaceBetween"
|
||||||
alignItems="Baseline"
|
alignItems="Center"
|
||||||
grow="Yes"
|
grow="Yes"
|
||||||
|
data-message-header=""
|
||||||
>
|
>
|
||||||
<Box alignItems="Center" gap="200">
|
<Box alignItems="Center" gap="200">
|
||||||
<Username
|
<Username
|
||||||
|
|||||||
@@ -376,6 +376,14 @@ function MessageNotifications() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Match TitleBar / room bell settings: Default and Mentions rooms only notify on highlights.
|
||||||
|
const notificationType = getNotificationType(mx, room.roomId);
|
||||||
|
const isDm = mDirects.has(room.roomId);
|
||||||
|
const mentionsOnly =
|
||||||
|
notificationType === NotificationType.MentionsAndKeywords ||
|
||||||
|
(notificationType === NotificationType.Default && !isDm);
|
||||||
|
if (mentionsOnly && unreadInfo.highlight === 0) return;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
showNotifications &&
|
showNotifications &&
|
||||||
((isTauri() && !isElectron()) || isCapacitorNative() || notificationPermission('granted'))
|
((isTauri() && !isElectron()) || isCapacitorNative() || notificationPermission('granted'))
|
||||||
@@ -397,7 +405,6 @@ function MessageNotifications() {
|
|||||||
messageBody = typeof content.body === 'string' ? content.body : undefined;
|
messageBody = typeof content.body === 'string' ? content.body : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDm = room.getJoinedMemberCount() === 2 && !room.isSpaceRoom();
|
|
||||||
notify({
|
notify({
|
||||||
roomName: room.name ?? 'Unknown',
|
roomName: room.name ?? 'Unknown',
|
||||||
roomAvatar: avatarMxc
|
roomAvatar: avatarMxc
|
||||||
@@ -428,6 +435,7 @@ function MessageNotifications() {
|
|||||||
notify,
|
notify,
|
||||||
selectedRoomId,
|
selectedRoomId,
|
||||||
useAuthentication,
|
useAuthentication,
|
||||||
|
mDirects,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ export const isCapacitorAndroid = Boolean(
|
|||||||
cap?.isNativePlatform?.() && cap?.getPlatform?.() === 'android'
|
cap?.isNativePlatform?.() && cap?.getPlatform?.() === 'android'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Stationery signatures use Caveat on all platforms (including Android WebView).
|
||||||
|
await import('@fontsource/caveat/500.css');
|
||||||
|
await import('@fontsource/caveat/600.css');
|
||||||
|
await import('@fontsource/caveat/700.css');
|
||||||
|
|
||||||
if (isCapacitorAndroid) {
|
if (isCapacitorAndroid) {
|
||||||
document.documentElement.classList.add('android-capacitor');
|
document.documentElement.classList.add('android-capacitor');
|
||||||
document.documentElement.style.setProperty(
|
document.documentElement.style.setProperty(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paarrot",
|
"name": "paarrot",
|
||||||
"version": "4.11.126",
|
"version": "4.11.133",
|
||||||
"description": "Paarrot - A Matrix client based on Cinny",
|
"description": "Paarrot - A Matrix client based on Cinny",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user