Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60edb063dd | ||
| 0c85c36838 | |||
|
|
d59646c630 | ||
| c1985eda15 | |||
|
|
2e018b0367 | ||
| 8046474b8a | |||
|
|
9821b16968 | ||
| a887cd39f1 |
@@ -102,29 +102,7 @@ jobs:
|
||||
|
||||
- name: Build Android APK
|
||||
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
|
||||
id: version
|
||||
run: |
|
||||
@@ -135,7 +113,14 @@ jobs:
|
||||
fi
|
||||
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
|
||||
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
|
||||
id: tag_check
|
||||
run: |
|
||||
@@ -147,7 +132,7 @@ jobs:
|
||||
echo "Tag v$VERSION does not exist"
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
|
||||
- name: Create and push tag
|
||||
if: steps.tag_check.outputs.exists == 'false'
|
||||
run: |
|
||||
@@ -155,18 +140,9 @@ jobs:
|
||||
git config user.email "actions@github.com"
|
||||
git tag -a "v${{ steps.version.outputs.VERSION }}" -m "Release v${{ steps.version.outputs.VERSION }}"
|
||||
git push origin "v${{ steps.version.outputs.VERSION }}"
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
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/
|
||||
|
||||
|
||||
# Upload via the internal Gitea API. actions/upload-artifact times out against
|
||||
# git.ruv.wtf:443 from this runner; synbox:8418 is reachable on the LAN.
|
||||
- name: Create or Update Gitea Release
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -175,10 +151,10 @@ jobs:
|
||||
TAG_NAME="v${VERSION}"
|
||||
API_BASE="http://synbox.ruv.wtf:8418/api/v1"
|
||||
REPO="litruv/cinny-mobile"
|
||||
|
||||
|
||||
RELEASE_ID=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API_BASE}/repos/${REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty')
|
||||
|
||||
|
||||
if [ -z "$RELEASE_ID" ]; then
|
||||
echo "Creating new release for ${TAG_NAME}..."
|
||||
RELEASE_ID=$(curl -s -X POST \
|
||||
@@ -198,7 +174,7 @@ jobs:
|
||||
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
echo "Uploading assets..."
|
||||
for FILE in release-files/*; do
|
||||
FILENAME="Paarrot-${VERSION}.apk"
|
||||
@@ -210,9 +186,9 @@ jobs:
|
||||
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}"
|
||||
echo ""
|
||||
done
|
||||
|
||||
|
||||
echo "Gitea release complete!"
|
||||
|
||||
|
||||
- name: Create GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GHTOKEN }}
|
||||
@@ -220,10 +196,10 @@ jobs:
|
||||
VERSION="${{ steps.version.outputs.VERSION }}"
|
||||
TAG_NAME="v${VERSION}"
|
||||
GITHUB_REPO="Paarrot/Paarrot-Mobile"
|
||||
|
||||
|
||||
RELEASE_ID=$(curl -s -H "Authorization: token ${GH_TOKEN}" \
|
||||
"https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty')
|
||||
|
||||
|
||||
if [ -z "$RELEASE_ID" ]; then
|
||||
echo "Creating new GitHub release for ${TAG_NAME}..."
|
||||
RELEASE_ID=$(curl -s -X POST \
|
||||
@@ -235,11 +211,11 @@ jobs:
|
||||
else
|
||||
echo "GitHub release exists with ID: ${RELEASE_ID}"
|
||||
fi
|
||||
|
||||
|
||||
echo "Uploading assets to GitHub..."
|
||||
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}//')
|
||||
|
||||
|
||||
for FILE in release-files/*; do
|
||||
FILENAME="Paarrot-${VERSION}.apk"
|
||||
echo "Uploading ${FILENAME} to GitHub..."
|
||||
@@ -250,5 +226,5 @@ jobs:
|
||||
"${UPLOAD_URL}?name=${FILENAME}"
|
||||
echo ""
|
||||
done
|
||||
|
||||
|
||||
echo "GitHub release complete!"
|
||||
|
||||
2
.gitmodules
vendored
2
.gitmodules
vendored
@@ -1,3 +1,3 @@
|
||||
[submodule "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) {
|
||||
val prefs = applicationContext.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
|
||||
val homeserver = prefs.getString(EXTRA_HOMESERVER, null)?.trimEnd('/') ?: return
|
||||
@@ -159,10 +174,20 @@ class MatrixSyncService : Service() {
|
||||
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
ensureMessageChannel(nm)
|
||||
|
||||
val notifyCtx = loadNotifyContext(homeserver, token, myUserId)
|
||||
|
||||
val roomIds = joinedRooms.keys().asSequence().toList()
|
||||
for (roomId in roomIds) {
|
||||
val timeline = joinedRooms.optJSONObject(roomId)
|
||||
?.optJSONObject("timeline") ?: continue
|
||||
val roomData = joinedRooms.optJSONObject(roomId) ?: 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
|
||||
|
||||
for (i in 0 until events.length()) {
|
||||
@@ -174,6 +199,8 @@ class MatrixSyncService : Service() {
|
||||
if (eventId.isNotBlank() && !shownEventIds.add(eventId)) continue
|
||||
|
||||
val content = event.optJSONObject("content") ?: continue
|
||||
if (mentionsOnly && !isSpecialMessage(content, notifyCtx)) continue
|
||||
|
||||
val msgtype = content.optString("msgtype")
|
||||
val rawBody = content.optString("body")
|
||||
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 {
|
||||
val cachedName = displayNameCache[mxid]
|
||||
if (cachedName != null) return UserProfile(cachedName, avatarCache[mxid])
|
||||
|
||||
2
cinny
2
cinny
Submodule cinny updated: 61d41900cc...b52926f7d8
@@ -7,6 +7,7 @@ import * as swipeCss from '../mobile/mobile-gestures.css';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { SidebarDockedCallPanel } from '../../features/call/SidebarDockedCallPanel';
|
||||
import { useShowCompactMasterView } from '../../hooks/useCompactNav';
|
||||
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
|
||||
|
||||
type PageRootProps = {
|
||||
nav: ReactNode;
|
||||
@@ -74,6 +75,7 @@ export function PageNav({
|
||||
grow={isMobile ? 'Yes' : undefined}
|
||||
className={classNames(css.PageNav({ size }), className)}
|
||||
shrink={isMobile ? 'Yes' : 'No'}
|
||||
data-page-nav=""
|
||||
>
|
||||
<Box grow="Yes" direction="Column">
|
||||
{children}
|
||||
@@ -89,6 +91,7 @@ export const PageNavHeader = as<'header', css.PageNavHeaderVariants>(
|
||||
className={classNames(css.PageNavHeader({ outlined }), className)}
|
||||
variant="Background"
|
||||
size="600"
|
||||
data-folder-tab="nav"
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
@@ -104,13 +107,16 @@ export function PageNavContent({
|
||||
scrollRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
scrollProps?: React.ComponentProps<typeof Scroll>;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const hideScrollbar = isStationeryTheme(theme);
|
||||
|
||||
return (
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Scroll
|
||||
ref={scrollRef}
|
||||
variant="Background"
|
||||
direction="Vertical"
|
||||
size="300"
|
||||
size={hideScrollbar ? '0' : '300'}
|
||||
hideTrack
|
||||
visibility="Hover"
|
||||
{...scrollProps}
|
||||
@@ -126,6 +132,7 @@ export const Page = as<'div'>(({ className, ...props }, ref) => (
|
||||
grow="Yes"
|
||||
direction="Column"
|
||||
className={classNames(ContainerColor({ variant: 'Surface' }), className)}
|
||||
data-page=""
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
@@ -137,6 +144,7 @@ export const PageHeader = as<'div', css.PageHeaderVariants>(
|
||||
as="header"
|
||||
size="600"
|
||||
className={classNames(css.PageHeader({ balance, outlined }), className)}
|
||||
data-folder-tab-bar=""
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
|
||||
@@ -802,8 +802,9 @@ export const Message = as<'div', MessageProps>(
|
||||
gap="300"
|
||||
direction={messageLayout === MessageLayout.Compact ? 'RowReverse' : 'Row'}
|
||||
justifyContent="SpaceBetween"
|
||||
alignItems="Baseline"
|
||||
alignItems="Center"
|
||||
grow="Yes"
|
||||
data-message-header=""
|
||||
>
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Username
|
||||
|
||||
@@ -376,6 +376,14 @@ function MessageNotifications() {
|
||||
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 (
|
||||
showNotifications &&
|
||||
((isTauri() && !isElectron()) || isCapacitorNative() || notificationPermission('granted'))
|
||||
@@ -397,7 +405,6 @@ function MessageNotifications() {
|
||||
messageBody = typeof content.body === 'string' ? content.body : undefined;
|
||||
}
|
||||
|
||||
const isDm = room.getJoinedMemberCount() === 2 && !room.isSpaceRoom();
|
||||
notify({
|
||||
roomName: room.name ?? 'Unknown',
|
||||
roomAvatar: avatarMxc
|
||||
@@ -428,6 +435,7 @@ function MessageNotifications() {
|
||||
notify,
|
||||
selectedRoomId,
|
||||
useAuthentication,
|
||||
mDirects,
|
||||
]);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -7,6 +7,11 @@ export const isCapacitorAndroid = Boolean(
|
||||
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) {
|
||||
document.documentElement.classList.add('android-capacitor');
|
||||
document.documentElement.style.setProperty(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paarrot",
|
||||
"version": "4.11.129",
|
||||
"version": "4.11.133",
|
||||
"description": "Paarrot - A Matrix client based on Cinny",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
|
||||
Reference in New Issue
Block a user